Compare commits
21 Commits
723b7be84a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a65f68ac | |||
| 1aec74216a | |||
| 807704aabe | |||
| 3cc7c0469b | |||
| c870efa15a | |||
| 100262b94f | |||
| c2525a6411 | |||
| 85438c4a3e | |||
| 5466630dbd | |||
| 5e7cee15e3 | |||
| 7594ca06f5 | |||
| 890988b72c | |||
| 540862d7d0 | |||
| cb35ee044c | |||
| 3b586ea92d | |||
| 7ef6e3d9a2 | |||
| 25e76679c5 | |||
| d31fca3cb3 | |||
| c29fdd750a | |||
| b86174782f | |||
| 88f6b4df93 |
100
.gitea/workflows/port-to-omp.yml
Normal file
100
.gitea/workflows/port-to-omp.yml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
name: port-to-omp
|
||||||
|
|
||||||
|
# Keep the omp port of ralpi in lockstep with this repo.
|
||||||
|
#
|
||||||
|
# Two jobs:
|
||||||
|
# - verify: port to a scratch dir, then typecheck + test the ported tree on
|
||||||
|
# EVERY push and PR. Source drift — a base change that breaks a port op —
|
||||||
|
# fails here, on the branch that introduced it, before it reaches master.
|
||||||
|
# (The ported tree contains the full test suite, so this also gives the
|
||||||
|
# base repo its CI test coverage.)
|
||||||
|
# - publish: regenerate the omp port into Mike/omp-ralpi and push it, but
|
||||||
|
# only on master, and only after verify passed — a broken port can never
|
||||||
|
# ship. The port commit lands in the omp repo, never here, so this
|
||||||
|
# workflow cannot re-trigger itself.
|
||||||
|
#
|
||||||
|
# Prerequisites on git.freno.me:
|
||||||
|
# - an access token with write:repository scope, stored as the repo secret
|
||||||
|
# PORTING_KEY (the workflow authenticates as https://Mike:<token>@…)
|
||||||
|
# - a registered Actions runner (act_runner) for this repo
|
||||||
|
# - the omp repo must exist (Mike/omp-ralpi)
|
||||||
|
#
|
||||||
|
# Manual run: Actions tab → Run workflow (workflow_dispatch), or push.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Port to scratch dir (drift check)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port-check"
|
||||||
|
cd "$RUNNER_TEMP/omp-port-check"
|
||||||
|
bun run typecheck
|
||||||
|
bun test
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Port to omp
|
||||||
|
env:
|
||||||
|
PORTING_KEY: ${{ secrets.PORTING_KEY }}
|
||||||
|
OMP_REPO: omp-ralpi
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Trim the secret: a stray newline from pasting silently breaks
|
||||||
|
# basic auth. Fail loudly when it is missing entirely.
|
||||||
|
PORTING_KEY="$(printf '%s' "${PORTING_KEY}" | tr -d '[:space:]')"
|
||||||
|
: "${PORTING_KEY:?PORTING_KEY secret is not set}" || exit 1
|
||||||
|
URL="https://Mike:${PORTING_KEY}@git.freno.me/Mike/${OMP_REPO}.git"
|
||||||
|
|
||||||
|
# The omp checkout lives in $RUNNER_TEMP, outside the pi checkout:
|
||||||
|
# the port script refuses to write into a subdirectory of its own
|
||||||
|
# source (cpSync would recurse into itself).
|
||||||
|
PORT_DIR="${RUNNER_TEMP:-/tmp}/omp-port"
|
||||||
|
|
||||||
|
# Preflight: reach the omp repo with the token. Fail with a clear
|
||||||
|
# message instead of a confusing error later at push time.
|
||||||
|
if ! git ls-remote "$URL" HEAD >/dev/null 2>&1; then
|
||||||
|
echo "::error::cannot read Mike/omp-ralpi with PORTING_KEY — is the secret set on this repo, valid, and write:repository-scoped?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git clone --depth 1 "$URL" "$PORT_DIR"
|
||||||
|
git -C "$PORT_DIR" config user.name "omp-port"
|
||||||
|
git -C "$PORT_DIR" config user.email "omp-port@freno.me"
|
||||||
|
|
||||||
|
# Regenerate the port directly into the omp checkout. The script
|
||||||
|
# preserves .git, asserts every patch rule, and runs `bun install`
|
||||||
|
# (refreshing bun.lock + node_modules).
|
||||||
|
bun "$GITHUB_WORKSPACE/port-to-omp.mjs" --out "$PORT_DIR"
|
||||||
|
|
||||||
|
cd "$PORT_DIR"
|
||||||
|
# The port must compile against the pinned @oh-my-pi SDK before it
|
||||||
|
# ships to users.
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
|
if git diff --quiet HEAD; then
|
||||||
|
echo "port unchanged; nothing to push"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git add -A
|
||||||
|
git commit -m "port: sync from ${GITHUB_REPOSITORY}@${GITHUB_SHA::8}"
|
||||||
|
git push origin HEAD:main
|
||||||
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
|
||||||
35
AGENTS.md
35
AGENTS.md
@@ -36,7 +36,7 @@ is unavailable).
|
|||||||
|
|
||||||
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
||||||
prompts, reload auto-resume via `session_start`, progress message renderer
|
prompts, progress message renderer
|
||||||
- `src/` — all logic modules:
|
- `src/` — all logic modules:
|
||||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||||
YAML formats), dependency + parallel-group + timeout parsing,
|
YAML formats), dependency + parallel-group + timeout parsing,
|
||||||
@@ -62,7 +62,6 @@ is unavailable).
|
|||||||
- `constants.ts` — static constants (slash command, task file names,
|
- `constants.ts` — static constants (slash command, task file names,
|
||||||
reflection/review patterns)
|
reflection/review patterns)
|
||||||
- `tests/` — bun test suites for parser and DAG behavior
|
- `tests/` — bun test suites for parser and DAG behavior
|
||||||
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
|
||||||
- `prompts/task-manager.md` — Pi prompt for task planning
|
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||||
|
|
||||||
## Runtime state
|
## Runtime state
|
||||||
@@ -70,18 +69,16 @@ is unavailable).
|
|||||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||||
|
|
||||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||||
- `.ralpi/loop-active.json` — marker written while a loop runs; drives
|
- `.ralpi/loop-active.json` — marker written while a loop runs; snapshots the
|
||||||
auto-resume after a session reload
|
mode + loop options so `/ralpi-resume` can continue non-interactively
|
||||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||||
`saveReviews` is on)
|
`saveReviews` is on)
|
||||||
|
- `.ralpi/sessions/` — agent session JSONL files per task (persisted so an
|
||||||
|
interrupted task can be resumed from its prior conversation)
|
||||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
- `.ralpi/config.yaml` — project-level config (optional)
|
- `.ralpi/config.yaml` — project-level config (optional)
|
||||||
|
|
||||||
There is no `.ralpi/sessions/` directory anymore — full task output is shown
|
|
||||||
inline via expandable `ralpi-progress` chat messages, and review output is
|
|
||||||
persisted under `.ralpi/reviews/`.
|
|
||||||
|
|
||||||
## Task ID convention
|
## Task ID convention
|
||||||
|
|
||||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
||||||
@@ -94,8 +91,10 @@ use raw numeric IDs.
|
|||||||
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||||
error suggesting the dash commands
|
error suggesting the dash commands
|
||||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
- `/ralpi-run [task-file]` — run tasks (always prompts for execution mode +
|
||||||
exists for the file; otherwise prompts for execution mode + loop options)
|
loop options; a cancelled loop for the file is continued from its progress
|
||||||
|
with the newly chosen settings — it never silently resumes with the old
|
||||||
|
loop's settings, only `/ralpi-resume` does that)
|
||||||
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||||
with `expandPromptTemplates: false`, so the extension does its own
|
with `expandPromptTemplates: false`, so the extension does its own
|
||||||
@@ -125,10 +124,24 @@ Key config fields in `execution`:
|
|||||||
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
||||||
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
||||||
mandated when review is on)
|
mandated when review is on)
|
||||||
|
- `inactivityTimeoutMs` — hang detection: if no agent session event arrives
|
||||||
|
within this window (e.g. a bash subprocess that never returns), the task is
|
||||||
|
aborted (agent abort + bash subprocess kill). `0` = disabled (default)
|
||||||
- `models` — slot-aware round-robin model list for parallel mode, with
|
- `models` — slot-aware round-robin model list for parallel mode, with
|
||||||
automatic failover to the next model per task
|
failover to the next model per task (only after exhausting same-model
|
||||||
|
retries, see `maxSameModelAttempts`)
|
||||||
|
- `maxSameModelAttempts` — max attempts on the SAME model before cycling to
|
||||||
|
the next model on failure (default 5, matching pi's normal retry count).
|
||||||
|
Applies to task execution, commit/review follow-up sessions, and
|
||||||
|
review-fix re-execution alike
|
||||||
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings
|
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings
|
||||||
resolved via `resolveModelSpec` in `utils.ts`
|
resolved via `resolveModelSpec` in `utils.ts`
|
||||||
|
- `prompts.reviewFocus` — per-review custom focus/instructions, injected as a
|
||||||
|
`## Custom Review Focus` section in review prompts
|
||||||
|
- `review.extraIgnorePatterns` — extra noise-filter exclusion regexes (file
|
||||||
|
paths) merged into the default rules
|
||||||
|
- `review.ignorePaths` — pathspec allowlist keeping matching files in review
|
||||||
|
scope even when a default noise rule would exclude them
|
||||||
- `maxReviewRetries` / `reviewBlockOnFail` — review-gated loop retry behavior
|
- `maxReviewRetries` / `reviewBlockOnFail` — review-gated loop retry behavior
|
||||||
- `worktrees` — `"never" | "parallel" | "always"` git worktree isolation
|
- `worktrees` — `"never" | "parallel" | "always"` git worktree isolation
|
||||||
(default `"parallel"`; see `shouldUseWorktrees` in `src/executor.ts`)
|
(default `"parallel"`; see `shouldUseWorktrees` in `src/executor.ts`)
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -218,11 +218,29 @@ execution:
|
|||||||
reviewTimeoutMs: 0 # timeout for auto-review agent sessions (0 = inherit)
|
reviewTimeoutMs: 0 # timeout for auto-review agent sessions (0 = inherit)
|
||||||
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit; checked between batches)
|
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit; checked between batches)
|
||||||
worktrees: parallel # "never" | "parallel" (default) | "always" — git worktree isolation
|
worktrees: parallel # "never" | "parallel" (default) | "always" — git worktree isolation
|
||||||
|
chatStyle: compact # "compact" (default) | "verbose" — per-event tool-call stream
|
||||||
prompts:
|
prompts:
|
||||||
projectContext: "Additional context for all tasks"
|
projectContext: "Additional context for all tasks"
|
||||||
reflectionPrompt: "" # custom suffix for reflection extraction
|
reflectionPrompt: "" # custom suffix for reflection extraction
|
||||||
|
reviewFocus: "" # per-review custom focus/instructions (e.g. "check security only")
|
||||||
|
review:
|
||||||
|
extraIgnorePatterns: [] # extra noise-filter exclusion regexes (merged into the default rules)
|
||||||
|
ignorePaths: [] # pathspec allowlist — files matching these stay in review scope
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Review prompts (committed + uncommitted) run the diff through a noise filter
|
||||||
|
before inlining: lockfiles, minified/generated assets, source maps,
|
||||||
|
snapshots, build output, `node_modules`/`vendor`, and binary/media files are
|
||||||
|
excluded by default. The prompt gets a per-file `+/−` summary table, an
|
||||||
|
`### Excluded Files (n)` section listing what was filtered (path, counts,
|
||||||
|
reason), and — when a diff is oversized or touches >20 files — a
|
||||||
|
file-list + "use `read`" instruction instead of a byte-truncated diff.
|
||||||
|
`prompts.reviewFocus` injects a `### Custom Review Focus` section into each
|
||||||
|
review prompt. `review.extraIgnorePatterns` adds exclusion regexes (matched
|
||||||
|
against file paths), and `review.ignorePaths` is a pathspec allowlist that
|
||||||
|
keeps matching files in review scope even when a default rule would exclude
|
||||||
|
them.
|
||||||
|
|
||||||
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
|
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
|
||||||
> tasks, only the first two models are used. The third model is only touched when
|
> tasks, only the first two models are used. The third model is only touched when
|
||||||
> a third concurrent task starts. Freed model slots are reused before new ones
|
> a third concurrent task starts. Freed model slots are reused before new ones
|
||||||
@@ -234,6 +252,10 @@ prompts:
|
|||||||
> (or parallel mode with no `models` list) the parent pi session's model is used,
|
> (or parallel mode with no `models` list) the parent pi session's model is used,
|
||||||
> unless `implModel` is set.
|
> unless `implModel` is set.
|
||||||
|
|
||||||
|
> `execution.chatStyle` controls how sub-agent tool calls appear in the chat during task execution:
|
||||||
|
> - **compact** (default): a single completion message per task with an expandable tool-call tree (collapsed shows the last 3 calls, expanded via Ctrl+O shows all).
|
||||||
|
> - **verbose**: each tool event is streamed live as its own chat line (`[01 · task-name] → bash ...` / `← (ok)`), like piolium/pygienium's per-event stream.
|
||||||
|
|
||||||
#### Auto-review and Auto-commit
|
#### Auto-review and Auto-commit
|
||||||
|
|
||||||
At loop startup the review question is asked FIRST. When `autoReview` is
|
At loop startup the review question is asked FIRST. When `autoReview` is
|
||||||
@@ -273,3 +295,9 @@ in parallel mode).
|
|||||||
.ralpi/prompts/ # Generated prompts (timestamped, for debugging)
|
.ralpi/prompts/ # Generated prompts (timestamped, for debugging)
|
||||||
.ralpi/config.yaml # Project-level config (optional)
|
.ralpi/config.yaml # Project-level config (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every `/ralpi run`, `/ralpi resume`, and `/ralpi reset` (plus the auto-resume
|
||||||
|
on session reload) ensures `.ralpi/` is present in the project's `.gitignore`,
|
||||||
|
so ralpi's own artifacts never show up as untracked/staged files in the user's
|
||||||
|
repo. Opt out per command with `--no-gitignore` (e.g. `/ralpi-run README.md
|
||||||
|
--no-gitignore`).
|
||||||
|
|||||||
500
index.ts
500
index.ts
@@ -19,7 +19,7 @@ import { loadTaskManagerPrompt } from "./src/task-manager-prompt";
|
|||||||
import { formatReflections } from "./src/reflection";
|
import { formatReflections } from "./src/reflection";
|
||||||
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||||
import type { ReviewResult } from "./src/types";
|
import type { ReviewResult } from "./src/types";
|
||||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
import { executeBatch, type SendChatMessage, setStreamForwarder } from "./src/executor";
|
||||||
import {
|
import {
|
||||||
cleanupStaleWorktrees,
|
cleanupStaleWorktrees,
|
||||||
finalizeCommittedWorktrees,
|
finalizeCommittedWorktrees,
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
deleteLoopActive,
|
deleteLoopActive,
|
||||||
readLoopActive,
|
readLoopActive,
|
||||||
findRalpiDir,
|
findRalpiDir,
|
||||||
|
ensureRalpiIgnored,
|
||||||
listPRDsSorted,
|
listPRDsSorted,
|
||||||
countPRDResumeStats,
|
countPRDResumeStats,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -43,6 +44,39 @@ type ExecutionMode = "parallel" | "sequential";
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a `--no-gitignore` opt-out out of the command args (in place). The
|
||||||
|
* flag controls whether `/ralpi run|resume|reset` auto-adds `.ralpi/` to the
|
||||||
|
* project's `.gitignore` — it defaults to on so ralpi's own artifacts never
|
||||||
|
* end up staged in the user's repo.
|
||||||
|
*/
|
||||||
|
function stripNoGitignore(args: string[]): boolean {
|
||||||
|
const i = args.indexOf("--no-gitignore");
|
||||||
|
if (i === -1) return false;
|
||||||
|
args.splice(i, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure `.ralpi/` is gitignored in the project (unless opted out), and
|
||||||
|
* notify once when the guard actually appended the entry.
|
||||||
|
*/
|
||||||
|
function ensureIgnoredNote(
|
||||||
|
projectDir: string,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
noGitignore = false,
|
||||||
|
): void {
|
||||||
|
if (noGitignore) return;
|
||||||
|
if (ensureRalpiIgnored(projectDir)) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"· .ralpi/ added to .gitignore (opt out with --no-gitignore)",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if a token looks like a file path rather than a subcommand.
|
* Detect if a token looks like a file path rather than a subcommand.
|
||||||
* Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
|
* Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
|
||||||
@@ -272,9 +306,10 @@ async function executePlanBatches(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write loop-active marker so a session reload can detect an interrupted
|
// Write the loop-active marker so an interrupted loop can be resumed
|
||||||
// loop and resume it (in-process agent sessions die on reload — the marker
|
// non-interactively via /ralpi-resume: it snapshots the execution mode and
|
||||||
// + progress.json in_progress tasks are the signal to re-run them).
|
// loop options (autoCommit/autoReview/saveReviews) that /ralpi-resume
|
||||||
|
// would otherwise re-prompt for.
|
||||||
if (projectDir) {
|
if (projectDir) {
|
||||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||||
writeLoopActive(projectDir, {
|
writeLoopActive(projectDir, {
|
||||||
@@ -435,9 +470,134 @@ function makeSendProgress(pi: ExtensionAPI): SendChatMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pick the one useful argument from a tool-call's args (path/command/…). */
|
||||||
|
function summarizeArgs(args: unknown): string {
|
||||||
|
if (!args || typeof args !== "object") return "";
|
||||||
|
const obj = args as Record<string, unknown>;
|
||||||
|
const pickKey = ["file_path", "path", "command", "pattern", "query", "url"].find(
|
||||||
|
(k) => typeof obj[k] === "string",
|
||||||
|
);
|
||||||
|
if (pickKey) {
|
||||||
|
const value = String(obj[pickKey]);
|
||||||
|
return value.length > 120 ? `${value.slice(0, 117)}…` : value;
|
||||||
|
}
|
||||||
|
const json = JSON.stringify(obj);
|
||||||
|
return json.length > 120 ? `${json.slice(0, 117)}…` : json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a tool result down to a single short line. */
|
||||||
|
function summarizeToolResult(result: unknown): string {
|
||||||
|
if (result == null) return "";
|
||||||
|
if (typeof result === "string") return result;
|
||||||
|
if (typeof result === "number" || typeof result === "boolean")
|
||||||
|
return String(result);
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
return result
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return item;
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"text" in (item as Record<string, unknown>)
|
||||||
|
)
|
||||||
|
return String((item as { text?: unknown }).text ?? "");
|
||||||
|
return JSON.stringify(item);
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
if (typeof result !== "object") return "";
|
||||||
|
const obj = result as Record<string, unknown>;
|
||||||
|
if (Array.isArray(obj.content)) {
|
||||||
|
const unwrapped = summarizeToolResult(obj.content);
|
||||||
|
if (unwrapped) return unwrapped;
|
||||||
|
}
|
||||||
|
const preferKey = ["stdout", "output", "text", "content", "result"].find(
|
||||||
|
(k) => typeof obj[k] === "string" && (obj[k] as string).length > 0,
|
||||||
|
);
|
||||||
|
if (preferKey) return obj[preferKey] as string;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse whitespace and cap a line at `max` chars with an ellipsis. */
|
||||||
|
function compactLine(text: string, max: number): string {
|
||||||
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (collapsed.length <= max) return collapsed;
|
||||||
|
return `${collapsed.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract joined text from an assistant message's content blocks. */
|
||||||
|
function extractAssistantTextFromContent(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
return content
|
||||||
|
.flatMap((c) =>
|
||||||
|
c && typeof c === "object" &&
|
||||||
|
(c as { type?: string }).type === "text"
|
||||||
|
? [(c as { text?: string }).text ?? ""]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stream-event forwarder that posts ralpi-stream messages (one chat
|
||||||
|
* line per tool start/end and assistant turn) into the chat. Only used when
|
||||||
|
* execution.chatStyle is "verbose".
|
||||||
|
*/
|
||||||
|
function makeStreamForwarder(pi: ExtensionAPI): (phase: string, event: import("@earendil-works/pi-coding-agent").AgentSessionEvent) => void {
|
||||||
|
const send = (details: {
|
||||||
|
kind: "tool-start" | "tool-end" | "tool-error" | "assistant";
|
||||||
|
phase: string;
|
||||||
|
toolName?: string;
|
||||||
|
body?: string;
|
||||||
|
}, fallback: string) => {
|
||||||
|
pi.sendMessage({
|
||||||
|
customType: "ralpi-stream",
|
||||||
|
content: fallback,
|
||||||
|
display: true,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (phase: string, event: import("@earendil-works/pi-coding-agent").AgentSessionEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "tool_execution_start": {
|
||||||
|
const body = summarizeArgs(event.args);
|
||||||
|
send({ kind: "tool-start", phase, toolName: event.toolName, body }, `[${phase}] → ${event.toolName}${body ? ` ${body}` : ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "tool_execution_end": {
|
||||||
|
const body = compactLine(summarizeToolResult(event.result), 200);
|
||||||
|
const kind = event.isError ? "tool-error" : "tool-end";
|
||||||
|
const marker = event.isError ? "✗" : "←";
|
||||||
|
send({ kind, phase, toolName: event.toolName, body }, `[${phase}] ${marker} ${event.toolName}${body ? ` ${body}` : ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "message_end": {
|
||||||
|
const message = event.message as { role?: string; content?: unknown };
|
||||||
|
if (message.role !== "assistant") return;
|
||||||
|
const text = extractAssistantTextFromContent(message.content).trim();
|
||||||
|
if (!text) return;
|
||||||
|
const head = compactLine(text, 240);
|
||||||
|
send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||||
|
// Wire the verbose stream forwarder — posts each tool event as its own
|
||||||
|
// chat message via the ralpi-stream renderer. Enabled per-run by
|
||||||
|
// `execution.chatStyle: verbose` in the config YAML.
|
||||||
|
setStreamForwarder(makeStreamForwarder(pi));
|
||||||
|
|
||||||
// Register custom message renderer for ralpi progress messages.
|
// Register custom message renderer for ralpi progress messages.
|
||||||
// Renders an expandable tool-call tree: collapsed shows last 3 + "N more",
|
// Renders an expandable tool-call tree: collapsed shows last 3 + "N more",
|
||||||
// expanded (Ctrl+O) shows every tool call.
|
// expanded (Ctrl+O) shows every tool call.
|
||||||
@@ -538,232 +698,72 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Reload detection: resume interrupted loops when session reloads ──
|
// ─── Verbose tool-event stream renderer ─────────────────────────────
|
||||||
//
|
//
|
||||||
// ralpi runs task agent sessions in-process (createAgentSession), so they
|
// When execution.chatStyle is "verbose", each tool_execution_start/end and
|
||||||
// do NOT survive a /reload. When the new session starts, this handler
|
// assistant turn is posted as its own chat message — the piolium/pygienium
|
||||||
// reads the persisted loop-active marker + progress.json: if any task is
|
// per-event stream. When "compact" (default), only the completion message
|
||||||
// still `in_progress`, the loop was interrupted mid-task and we resume it
|
// with its expandable tool-call tree shows (the existing ralpi-progress
|
||||||
// (resetting those tasks to pending so the DAG re-schedules them), using
|
// renderer above).
|
||||||
// the mode + loop options snapshotted in loop-active.json so the resume is
|
|
||||||
// non-interactive.
|
|
||||||
pi.on("session_start", async (event, ctx) => {
|
|
||||||
if (event.reason !== "reload") return;
|
|
||||||
|
|
||||||
// Find the ralpi project directory
|
type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant";
|
||||||
const projectDir = findRalpiDir(ctx.cwd);
|
|
||||||
if (!projectDir) return;
|
|
||||||
|
|
||||||
// Check if a task execution loop was active before the reload
|
interface StreamLineDetails {
|
||||||
const loopState = readLoopActive(projectDir);
|
kind: StreamLineKind;
|
||||||
if (!loopState) return;
|
phase: string;
|
||||||
|
toolName?: string;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Load progress state
|
pi.registerMessageRenderer<StreamLineDetails>(
|
||||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
"ralpi-stream",
|
||||||
|
(message, _options, theme) => {
|
||||||
/** Re-read progress from disk. */
|
const details = message.details;
|
||||||
const readTasks = (): Record<string, { status: string }> | null => {
|
if (!details || typeof details !== "object") {
|
||||||
try {
|
const fallback =
|
||||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
typeof message.content === "string" ? message.content : "";
|
||||||
const parsed = JSON.parse(raw) as Record<string, any>;
|
return new Text(theme.fg("muted", fallback), 0, 0);
|
||||||
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
const { kind, phase, toolName, body } = details;
|
||||||
|
const phaseTag = theme.fg("accent", `[${phase}]`);
|
||||||
// ralpi agent sessions run in-process (createAgentSession), so they do
|
const indent = " ".repeat(phase.length + 3);
|
||||||
// NOT survive a session reload. Any task left `in_progress` is therefore
|
let line: string;
|
||||||
// stalled — its agent died with the previous session. Detect that state
|
switch (kind) {
|
||||||
// and actively resume the loop instead of passively polling (which would
|
case "tool-start": {
|
||||||
// spin forever waiting for a dead task to complete).
|
const arrow = theme.fg("muted", "→");
|
||||||
const initialTasks = readTasks();
|
const name = theme.fg("toolTitle", theme.bold(toolName ?? ""));
|
||||||
if (initialTasks) {
|
const args = body ? ` ${theme.fg("muted", body)}` : "";
|
||||||
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
line = `${phaseTag} ${arrow} ${name}${args}`;
|
||||||
t.status === "in_progress" ? [id] : [],
|
break;
|
||||||
);
|
|
||||||
|
|
||||||
// Build the sendProgress wrapper so resumed task messages render the
|
|
||||||
// same expandable tool-call tree as an interactive run. Defined before
|
|
||||||
// the finalize path below so it can report self-healed merges.
|
|
||||||
const sendProgress: SendChatMessage = (
|
|
||||||
content: string,
|
|
||||||
meta?: {
|
|
||||||
toolCalls?: Array<{ name: string; label: string }>;
|
|
||||||
reviewText?: string;
|
|
||||||
reviewPath?: string;
|
|
||||||
reviewResult?: ReviewResult;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
pi.sendMessage({
|
|
||||||
customType: "ralpi-progress",
|
|
||||||
content,
|
|
||||||
display: true,
|
|
||||||
details: {
|
|
||||||
phase: "progress",
|
|
||||||
toolCalls: meta?.toolCalls,
|
|
||||||
reviewText: meta?.reviewText,
|
|
||||||
reviewPath: meta?.reviewPath,
|
|
||||||
reviewResult: meta?.reviewResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inProgressIds.length === 0) {
|
|
||||||
// Nothing was mid-flight — the loop either finished cleanly between
|
|
||||||
// the reload landing and this handler running, or was stopped
|
|
||||||
// between tasks. Either way, committed worktree branches from an
|
|
||||||
// interrupted loop may still be unmerged (e.g. a prior resume
|
|
||||||
// attempt reset tasks to pending before it was itself interrupted).
|
|
||||||
// Finalize those first so committed code lands in the workspace,
|
|
||||||
// persist the state to progress.json, update the PRD file, THEN
|
|
||||||
// clean up the stale marker.
|
|
||||||
try {
|
|
||||||
const config = loadConfig(projectDir);
|
|
||||||
// Clear any half-done merge left by an interrupted
|
|
||||||
// conflict-resolution session (it would block every merge below).
|
|
||||||
abortMerge(projectDir);
|
|
||||||
const allIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
|
||||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
|
||||||
);
|
|
||||||
const fin = finalizeCommittedWorktrees(
|
|
||||||
projectDir,
|
|
||||||
config.paths.stateDir,
|
|
||||||
loopState.prdKey,
|
|
||||||
allIds,
|
|
||||||
);
|
|
||||||
// Persist finalized tasks to progress.json + PRD file so the
|
|
||||||
// state is correct for subsequent /ralpi resume calls.
|
|
||||||
const stateDir = config.paths.stateDir;
|
|
||||||
const progressPath = path.join(projectDir, stateDir, "progress.json");
|
|
||||||
// Batch-update progress.json and PRD file for all finalized tasks
|
|
||||||
if (fin.finalized.length > 0) {
|
|
||||||
const progressRaw = fs.existsSync(progressPath)
|
|
||||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
|
||||||
: null;
|
|
||||||
for (const id of fin.finalized) {
|
|
||||||
sendProgress?.(
|
|
||||||
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
|
||||||
);
|
|
||||||
if (progressRaw) {
|
|
||||||
const tasks =
|
|
||||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
|
||||||
progressRaw.tasks;
|
|
||||||
if (tasks && tasks[id]) {
|
|
||||||
tasks[id].status = "completed";
|
|
||||||
tasks[id].completedAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const prdPath = loopState.taskFile;
|
|
||||||
if (fs.existsSync(prdPath)) {
|
|
||||||
updateTaskInFile(prdPath, id, "completed");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (progressRaw) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
progressPath,
|
|
||||||
JSON.stringify(progressRaw, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ── Handle conflicted tasks ──
|
|
||||||
// Same logic as resumeLoop: reset to pending so the DAG can
|
|
||||||
// re-schedule them, keep the worktree for in-place re-run.
|
|
||||||
const conflictIds = Object.keys(fin.conflicts);
|
|
||||||
if (conflictIds.length > 0) {
|
|
||||||
const detail = conflictIds
|
|
||||||
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
|
||||||
.join("; ");
|
|
||||||
// Batch-reset all conflicted tasks to pending, then write once
|
|
||||||
const progressRaw = fs.existsSync(progressPath)
|
|
||||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
|
||||||
: null;
|
|
||||||
for (const id of conflictIds) {
|
|
||||||
if (progressRaw) {
|
|
||||||
const tasks =
|
|
||||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
|
||||||
progressRaw.tasks;
|
|
||||||
if (tasks && tasks[id]) {
|
|
||||||
tasks[id].status = "pending";
|
|
||||||
delete tasks[id].startedAt;
|
|
||||||
delete tasks[id].error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const prdPath = loopState.taskFile;
|
|
||||||
if (fs.existsSync(prdPath)) {
|
|
||||||
updateTaskInFile(prdPath, id, "pending");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (progressRaw) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
progressPath,
|
|
||||||
JSON.stringify(progressRaw, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort — the marker is removed either way; the worktrees
|
|
||||||
// stay on disk for a manual /ralpi-resume.
|
|
||||||
}
|
}
|
||||||
ctx.ui.notify(
|
case "tool-end": {
|
||||||
"ralpi loop has no in-progress task to resume — marking complete.",
|
const arrow = theme.fg("success", "←");
|
||||||
"info",
|
const result = body
|
||||||
);
|
? ` ${theme.fg("dim", body)}`
|
||||||
deleteLoopActive(projectDir);
|
: ` ${theme.fg("dim", "(ok)")}`;
|
||||||
return;
|
line = `${indent}${arrow}${result}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool-error": {
|
||||||
|
const marker = theme.fg("error", "✗");
|
||||||
|
const result = body
|
||||||
|
? ` ${theme.fg("error", body)}`
|
||||||
|
: ` ${theme.fg("error", "failed")}`;
|
||||||
|
line = `${indent}${marker}${result}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "assistant":
|
||||||
|
line = `${phaseTag} ${theme.fg("muted", body ?? "")}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
line =
|
||||||
|
typeof message.content === "string"
|
||||||
|
? theme.fg("muted", message.content)
|
||||||
|
: "";
|
||||||
}
|
}
|
||||||
|
return new Text(line, 0, 0);
|
||||||
const taskCount = loopState.taskIds.length;
|
},
|
||||||
ctx.ui.notify(
|
);
|
||||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
|
||||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load config from the project directory so model + thinking level
|
|
||||||
// resolve the same way the interactive command handler does.
|
|
||||||
const config = loadConfig(projectDir);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await resumeLoop(
|
|
||||||
ctx,
|
|
||||||
loopState.taskFile,
|
|
||||||
projectDir,
|
|
||||||
loopState.prdKey,
|
|
||||||
sendProgress,
|
|
||||||
config.model ?? ctx.model,
|
|
||||||
pi.getThinkingLevel(),
|
|
||||||
{
|
|
||||||
mode: loopState.mode,
|
|
||||||
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
|
|
||||||
autoReview: loopState.autoReview ?? config.execution.autoReview,
|
|
||||||
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
|
|
||||||
skipFinalStatus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
|
||||||
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
|
|
||||||
// Leave loop-active.json in place so the user can retry via
|
|
||||||
// /ralpi resume after addressing the underlying error.
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("ralpi", {
|
pi.registerCommand("ralpi", {
|
||||||
description:
|
description:
|
||||||
@@ -885,42 +885,21 @@ async function handleRun(
|
|||||||
parentModel?: unknown,
|
parentModel?: unknown,
|
||||||
parentThinkingLevel?: unknown,
|
parentThinkingLevel?: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const noGitignore = stripNoGitignore(args);
|
||||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||||
|
|
||||||
// If targeting a specific task file and there's existing progress for it,
|
// A cancelled loop for this file is continued below AFTER re-prompting for
|
||||||
// auto-resume instead of starting fresh
|
// settings — /ralpi-run never silently resumes with the old loop's
|
||||||
|
// settings (only /ralpi-resume does that). isResume below keeps the
|
||||||
|
// interrupted task's worktree so it continues rather than restarts.
|
||||||
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
||||||
if (existingProgress) {
|
|
||||||
return handleResume(
|
|
||||||
ctx,
|
|
||||||
args.slice(0, 1),
|
|
||||||
sendChatMessage,
|
|
||||||
parentModel,
|
|
||||||
parentThinkingLevel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No existing progress for this task — check for any progress at all
|
// Resolve the project root from any existing progress so running from a
|
||||||
|
// subdirectory still targets the loop's project.
|
||||||
const found = findProgressFile(ctx.cwd);
|
const found = findProgressFile(ctx.cwd);
|
||||||
if (found && !args[0]) {
|
|
||||||
// Offer to resume instead of starting fresh
|
|
||||||
const shouldResume = await ctx.ui.select(
|
|
||||||
"Found existing ralpi progress. Resume?",
|
|
||||||
["Yes, resume", "No, start fresh"],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldResume?.startsWith("Yes")) {
|
|
||||||
return handleResume(
|
|
||||||
ctx,
|
|
||||||
[],
|
|
||||||
sendChatMessage,
|
|
||||||
parentModel,
|
|
||||||
parentThinkingLevel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||||
|
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||||
|
|
||||||
const project = parseTaskFile(taskFile);
|
const project = parseTaskFile(taskFile);
|
||||||
const config = loadConfig(projectDir);
|
const config = loadConfig(projectDir);
|
||||||
@@ -964,6 +943,7 @@ async function handleRun(
|
|||||||
mode,
|
mode,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
|
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
|
||||||
);
|
);
|
||||||
|
|
||||||
const state = progress.getState();
|
const state = progress.getState();
|
||||||
@@ -985,11 +965,10 @@ async function handleRun(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume core: given a resolved task file, project dir, and PRD key,
|
* Resume core: given a resolved task file, project dir, and PRD key,
|
||||||
* build the remaining plan and execute it. Used by both the explicit
|
* build the remaining plan and execute it.
|
||||||
* `/ralpi resume` command and the auto-resume on session reload.
|
|
||||||
*
|
*
|
||||||
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
||||||
* passed to skip interactive prompts — this is how a reload resumes
|
* passed to skip interactive prompts — this is how /ralpi-resume resumes
|
||||||
* non-interactively using the snapshot stored in loop-active.json.
|
* non-interactively using the snapshot stored in loop-active.json.
|
||||||
* When omitted, the user is prompted as usual.
|
* When omitted, the user is prompted as usual.
|
||||||
*/
|
*/
|
||||||
@@ -1200,6 +1179,7 @@ async function handleResume(
|
|||||||
parentModel?: unknown,
|
parentModel?: unknown,
|
||||||
parentThinkingLevel?: unknown,
|
parentThinkingLevel?: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const noGitignore = stripNoGitignore(args);
|
||||||
let taskFile: string;
|
let taskFile: string;
|
||||||
let projectDir: string;
|
let projectDir: string;
|
||||||
let prdKey: string | undefined;
|
let prdKey: string | undefined;
|
||||||
@@ -1244,8 +1224,8 @@ async function handleResume(
|
|||||||
|
|
||||||
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||||
// persisted when the loop started, so an interrupted loop resumes
|
// persisted when the loop started, so an interrupted loop resumes
|
||||||
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
// non-interactively. Only fall back to interactive prompts when no
|
||||||
// back to interactive prompts when no snapshot is present.
|
// snapshot is present.
|
||||||
const snapshot = readLoopActive(projectDir);
|
const snapshot = readLoopActive(projectDir);
|
||||||
const loopOpts = (() => {
|
const loopOpts = (() => {
|
||||||
if (
|
if (
|
||||||
@@ -1266,6 +1246,8 @@ async function handleResume(
|
|||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||||
|
|
||||||
await resumeLoop(
|
await resumeLoop(
|
||||||
ctx,
|
ctx,
|
||||||
taskFile,
|
taskFile,
|
||||||
@@ -1287,6 +1269,7 @@ async function handleReset(
|
|||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
args: string[],
|
args: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const noGitignore = stripNoGitignore(args);
|
||||||
let sourcePath: string;
|
let sourcePath: string;
|
||||||
let prdKey: string | undefined;
|
let prdKey: string | undefined;
|
||||||
let progress: ProgressTracker;
|
let progress: ProgressTracker;
|
||||||
@@ -1295,6 +1278,7 @@ async function handleReset(
|
|||||||
const taskFile = resolveTaskArg(args[0], ctx.cwd);
|
const taskFile = resolveTaskArg(args[0], ctx.cwd);
|
||||||
const found = findProgressFile(ctx.cwd, taskFile);
|
const found = findProgressFile(ctx.cwd, taskFile);
|
||||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||||
|
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||||
sourcePath = taskFile;
|
sourcePath = taskFile;
|
||||||
prdKey = found?.prdKey;
|
prdKey = found?.prdKey;
|
||||||
progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
||||||
@@ -1309,6 +1293,8 @@ async function handleReset(
|
|||||||
}
|
}
|
||||||
const projectDir = path.dirname(path.dirname(found.path));
|
const projectDir = path.dirname(path.dirname(found.path));
|
||||||
|
|
||||||
|
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||||
|
|
||||||
// Multiple loops may have progress — let the user select which one to
|
// Multiple loops may have progress — let the user select which one to
|
||||||
// reset (sorted by most recent first), same as resume.
|
// reset (sorted by most recent first), same as resume.
|
||||||
const selected = await selectPRD(
|
const selected = await selectPRD(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mikefreno/ralpi",
|
"name": "@mikefreno/ralpi",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"src/",
|
"src/",
|
||||||
"skills/",
|
|
||||||
"prompts/",
|
"prompts/",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
@@ -41,9 +40,6 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
],
|
],
|
||||||
"skills": [
|
|
||||||
"./skills"
|
|
||||||
],
|
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"./prompts"
|
"./prompts"
|
||||||
]
|
]
|
||||||
|
|||||||
427
port-to-omp.mjs
Normal file
427
port-to-omp.mjs
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* port-to-omp.mjs — regenerate the omp port of ralpi from this repo.
|
||||||
|
*
|
||||||
|
* The omp port is "base + patch layer"; this script IS the patch layer. This
|
||||||
|
* repo is the single source of truth; ~/.omp/agent/extensions/ralpi (or
|
||||||
|
* --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/ralpi
|
||||||
|
* bun port-to-omp.mjs --out <dir> # write elsewhere (CI: the omp repo clone)
|
||||||
|
*
|
||||||
|
* CI: .gitea/workflows/port-to-omp.yml clones the omp-ralpi repo and
|
||||||
|
* runs this script into it, then commits + pushes when the port changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
cpSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import {
|
||||||
|
join,
|
||||||
|
relative,
|
||||||
|
resolve,
|
||||||
|
isAbsolute,
|
||||||
|
dirname,
|
||||||
|
basename,
|
||||||
|
} from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
|
const HOME = homedir();
|
||||||
|
const PI = join(HOME, ".pi", "agent", "extensions");
|
||||||
|
const OMP = join(HOME, ".omp", "agent", "extensions");
|
||||||
|
|
||||||
|
const SKIP = new Set([
|
||||||
|
"port-to-omp.mjs",
|
||||||
|
"release-tag.sh",
|
||||||
|
".gitea",
|
||||||
|
".github",
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
".DS_Store",
|
||||||
|
".pi-lens",
|
||||||
|
"bun.lock",
|
||||||
|
"package-lock.json",
|
||||||
|
"dist",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OMP_SDK = "17.2.12";
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Appended to op failures: the base repo is the source of truth, so a failed
|
||||||
|
* assertion means the source drifted and the op needs updating — never a
|
||||||
|
* reason to weaken the assertion. */
|
||||||
|
const DRIFT_HINT =
|
||||||
|
"\n\nBase repo drifted since this op was written — update the op (from/to) in" +
|
||||||
|
" port-to-omp.mjs. The verify CI job runs this script on every push, so this" +
|
||||||
|
" should surface on the branch that introduced the drift.";
|
||||||
|
|
||||||
|
/** Point at the first divergence between an expected op target and the actual
|
||||||
|
* file text, with a little surrounding context. */
|
||||||
|
function driftHint(src, from) {
|
||||||
|
const fromLines = from.split("\n");
|
||||||
|
const srcLines = src.split("\n");
|
||||||
|
const needle = fromLines[0].slice(0, 60);
|
||||||
|
// indexOf failed, so no full match exists. Anchor on the occurrence of the
|
||||||
|
// op's opening line that shares the LONGEST consecutive run with the
|
||||||
|
// expected text — a bare ` try {` can match unrelated blocks.
|
||||||
|
let anchor = -1;
|
||||||
|
let bestRun = -1;
|
||||||
|
for (let i = 0; i < srcLines.length; i++) {
|
||||||
|
if (!srcLines[i].includes(needle)) continue;
|
||||||
|
let run = 0;
|
||||||
|
while (run < fromLines.length && fromLines[run] === srcLines[i + run]) run++;
|
||||||
|
if (run > bestRun) {
|
||||||
|
bestRun = run;
|
||||||
|
anchor = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anchor === -1) {
|
||||||
|
return `\n\ncould not locate the op's opening line anywhere in the file:\n ${fromLines[0]}`;
|
||||||
|
}
|
||||||
|
const ctx = 2;
|
||||||
|
const i = Math.min(bestRun, fromLines.length - 1);
|
||||||
|
const before = srcLines
|
||||||
|
.slice(Math.max(0, anchor + i - ctx), anchor + i)
|
||||||
|
.map((l) => ` ${l}`);
|
||||||
|
const after = srcLines
|
||||||
|
.slice(anchor + i + 1, anchor + i + 1 + ctx)
|
||||||
|
.map((l) => ` ${l}`);
|
||||||
|
return (
|
||||||
|
`\n\nop matches at file line ${anchor + 1} for ${bestRun} line(s), then diverges at expected line ${i + 1}:\n` +
|
||||||
|
` expected: ${fromLines[i]}\n` +
|
||||||
|
` actual : ${srcLines[anchor + i] ?? "<end of file>"}\n` +
|
||||||
|
(before.length ? `\nactual context before:\n${before.join("\n")}\n` : "") +
|
||||||
|
(after.length ? `\nactual context after:\n${after.join("\n")}` : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEdit(src, from, to, file, label = "") {
|
||||||
|
const idx = src.indexOf(from);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
|
||||||
|
src,
|
||||||
|
from,
|
||||||
|
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return src.slice(0, idx) + to + src.slice(idx + from.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll(src, from, to, file, label) {
|
||||||
|
const parts = src.split(from);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
throw new Error(
|
||||||
|
`[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
|
||||||
|
src,
|
||||||
|
from,
|
||||||
|
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reEdit(src, re, to, file, label) {
|
||||||
|
const out = src.replace(re, to);
|
||||||
|
if (out === src) {
|
||||||
|
throw new Error(
|
||||||
|
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reAll(src, re, to, file, label) {
|
||||||
|
let count = 0;
|
||||||
|
const out = src.replace(re, (...args) => {
|
||||||
|
count++;
|
||||||
|
return typeof to === "function" ? to(...args) : to;
|
||||||
|
});
|
||||||
|
if (count === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a list of ops over a file's text. op = {from,to} | {re,to,label}. */
|
||||||
|
function applyOps(src, ops, file) {
|
||||||
|
for (const op of ops) {
|
||||||
|
if ("from" in op) {
|
||||||
|
src = op.all
|
||||||
|
? replaceAll(src, op.from, op.to, file, op.label)
|
||||||
|
: assertEdit(src, op.from, op.to, file, op.label);
|
||||||
|
} else {
|
||||||
|
src = op.all
|
||||||
|
? reAll(src, op.re, op.to, file, op.label)
|
||||||
|
: reEdit(src, op.re, op.to, file, op.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mirrorTree(srcDir, dstDir) {
|
||||||
|
mkdirSync(dstDir, { recursive: true });
|
||||||
|
cpSync(srcDir, dstDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
filter: (p) => !SKIP.has(p.split("/").pop()),
|
||||||
|
});
|
||||||
|
// drop stale files in dst that no longer exist in src; keep .git* intact
|
||||||
|
for (const rel of walk(dstDir)) {
|
||||||
|
if (rel.startsWith(".git")) continue;
|
||||||
|
if (!existsSync(join(srcDir, rel)))
|
||||||
|
rmSync(join(dstDir, rel), { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function real(p) {
|
||||||
|
try {
|
||||||
|
return realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
// walk to the nearest existing ancestor and realpath it, then re-append
|
||||||
|
const tail = [];
|
||||||
|
let cur = resolve(p);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return join(realpathSync(cur), ...tail);
|
||||||
|
} catch {}
|
||||||
|
const parent = dirname(cur);
|
||||||
|
if (parent === cur) return resolve(p);
|
||||||
|
tail.unshift(basename(cur));
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDstOutsideSrc(srcDir, dstDir) {
|
||||||
|
const rel = relative(real(srcDir), real(dstDir));
|
||||||
|
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
|
||||||
|
throw new Error(
|
||||||
|
`refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function walk(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (SKIP.has(entry.name)) continue;
|
||||||
|
const p = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory())
|
||||||
|
out.push(...walk(p).map((r) => join(entry.name, r)));
|
||||||
|
else out.push(entry.name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECIFIERS = [
|
||||||
|
["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"],
|
||||||
|
["@earendil-works/pi-tui", "@oh-my-pi/pi-tui"],
|
||||||
|
["@earendil-works/pi-ai", "@oh-my-pi/pi-ai"],
|
||||||
|
["@earendil-works/pi-agent-core", "@oh-my-pi/pi-agent-core"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function rewriteSpecifiers(src) {
|
||||||
|
for (const [from, to] of SPECIFIERS) src = src.split(from).join(to);
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── package.json transforms ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pkgName(piName) {
|
||||||
|
if (piName.startsWith("@mikefreno/"))
|
||||||
|
return piName.replace(/^@mikefreno\//, "@mikefreno/omp-");
|
||||||
|
return `@mikefreno/omp-${piName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorder(obj, keys) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of keys) if (k in obj) out[k] = obj[k];
|
||||||
|
for (const k of Object.keys(obj)) if (!(k in out)) out[k] = obj[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PKG_RULES = {
|
||||||
|
order: [
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"description",
|
||||||
|
"keywords",
|
||||||
|
"author",
|
||||||
|
"license",
|
||||||
|
"homepage",
|
||||||
|
"repository",
|
||||||
|
"bugs",
|
||||||
|
"files",
|
||||||
|
"scripts",
|
||||||
|
"engines",
|
||||||
|
"omp",
|
||||||
|
"dependencies",
|
||||||
|
"publishConfig",
|
||||||
|
"devDependencies",
|
||||||
|
],
|
||||||
|
transform(p) {
|
||||||
|
p.name = pkgName(p.name);
|
||||||
|
p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)];
|
||||||
|
delete p.scripts.prepublishOnly;
|
||||||
|
p.engines.bun = ">=1.3.14";
|
||||||
|
p.omp = p.pi;
|
||||||
|
delete p.pi;
|
||||||
|
delete p.omp.prompts;
|
||||||
|
delete p.peerDependencies;
|
||||||
|
p.devDependencies = {
|
||||||
|
"@oh-my-pi/pi-coding-agent": OMP_SDK,
|
||||||
|
"@oh-my-pi/pi-tui": OMP_SDK,
|
||||||
|
...p.devDependencies,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function transformPkg() {
|
||||||
|
const raw = JSON.parse(
|
||||||
|
readFileSync(join(import.meta.dir, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
if (!raw.pi) throw new Error('expected "pi" manifest key in package.json');
|
||||||
|
const rule = PKG_RULES;
|
||||||
|
rule.transform(raw);
|
||||||
|
const ordered = reorder(raw, rule.order);
|
||||||
|
return JSON.stringify(ordered, null, 2) + "\n";
|
||||||
|
}
|
||||||
|
const README_STUB = `# ralpi (omp port)
|
||||||
|
|
||||||
|
Execute tasks from task files using DAG-based dependency resolution.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
omp install @mikefreno/omp-ralpi
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
This is the omp port of [Mike/ralpi](https://git.freno.me/Mike/ralpi), 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/utils.ts": [
|
||||||
|
{
|
||||||
|
from: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";',
|
||||||
|
to: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";',
|
||||||
|
label: "import block",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "/** Path to the global ralpi config under the user's Pi home directory. */",
|
||||||
|
to: "/** Path to the global ralpi config under the user's omp home directory. */",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",',
|
||||||
|
to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",',
|
||||||
|
label: "GLOBAL_CONFIG_PATH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "~/.pi/ralpi/config.yaml",
|
||||||
|
to: "~/.omp/ralpi/config.yaml",
|
||||||
|
all: true,
|
||||||
|
label: "config path mentions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: " /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,",
|
||||||
|
to: " /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,",
|
||||||
|
label: "modelRuntime signature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: ' try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = SessionManager.open(resumeSessionFile, sessionDir, cwd);\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });',
|
||||||
|
to: ' try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = await SessionManager.open(resumeSessionFile, sessionDir, undefined, {\n initialCwd: cwd,\n });\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });',
|
||||||
|
label: "runAgentSession body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"src/executor.ts": [
|
||||||
|
{
|
||||||
|
from: 'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
|
||||||
|
to: 'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
|
||||||
|
label: "ModelRuntime import removal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "(ctx.modelRegistry as any).runtime as ModelRuntime,",
|
||||||
|
to: "ctx.modelRegistry,",
|
||||||
|
all: true,
|
||||||
|
label: "modelRegistry pass-through",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on",
|
||||||
|
to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on",
|
||||||
|
label: "retry comment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: '\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));',
|
||||||
|
to: '\t\tcase "glob":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);',
|
||||||
|
label: "tool labeler find/ls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"src/task-manager-prompt.ts": [
|
||||||
|
{
|
||||||
|
from: 'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");',
|
||||||
|
to: 'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}',
|
||||||
|
label: "vendor stripFrontmatter",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const OUT_DEFAULT = join(homedir(), ".omp", "agent", "extensions", "ralpi");
|
||||||
|
|
||||||
|
function portExtension() {
|
||||||
|
const srcDir = import.meta.dir;
|
||||||
|
const dstDir = process.argv.includes("--out")
|
||||||
|
? process.argv[process.argv.indexOf("--out") + 1]
|
||||||
|
: OUT_DEFAULT;
|
||||||
|
if (!dstDir) throw new Error("--out requires a directory argument");
|
||||||
|
if (!existsSync(srcDir)) throw new Error(`no base extension at ${srcDir}`);
|
||||||
|
|
||||||
|
assertDstOutsideSrc(srcDir, dstDir);
|
||||||
|
console.log(`== ${srcDir} -> ${dstDir}`);
|
||||||
|
mirrorTree(srcDir, dstDir);
|
||||||
|
|
||||||
|
for (const rel of walk(dstDir)) {
|
||||||
|
const p = join(dstDir, rel);
|
||||||
|
if (!existsSync(p)) continue;
|
||||||
|
if (rel.endsWith(".ts")) {
|
||||||
|
let text = readFileSync(p, "utf8");
|
||||||
|
text = rewriteSpecifiers(text);
|
||||||
|
if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel);
|
||||||
|
writeFileSync(p, text);
|
||||||
|
} else if (rel === "package.json") {
|
||||||
|
writeFileSync(p, transformPkg());
|
||||||
|
} else if (rel === "README.md") {
|
||||||
|
writeFileSync(p, README_STUB);
|
||||||
|
} else if (rel.endsWith(".md")) {
|
||||||
|
let text = readFileSync(p, "utf8");
|
||||||
|
if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel);
|
||||||
|
writeFileSync(p, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
portExtension();
|
||||||
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."
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
description: Execute tasks from ralpi task files / PRDs using DAG-based dependency resolution, with persistent progress tracking and reflection support
|
|
||||||
---
|
|
||||||
|
|
||||||
# ralpi-task
|
|
||||||
|
|
||||||
Execute tasks from a ralpi task file (checkbox, Fio, phased, or YAML) using DAG-based dependency resolution, with persistent progress tracking and reflection support.
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
- User asks to execute a task file, PRD, or task list (e.g. "run the tasks", "execute the plan")
|
|
||||||
- User wants to run a full ralpi loop from a task file in the project
|
|
||||||
- User wants to resume an interrupted or paused ralpi run
|
|
||||||
- User wants to plan new tasks with the task-manager prompt
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/ralpi [task-file] # No args → show plan; path arg → run all tasks
|
|
||||||
/ralpi-run [task-file] # Run all tasks (auto-resumes if progress exists)
|
|
||||||
/ralpi-resume [task-file] # Resume paused/interrupted execution
|
|
||||||
/ralpi-plan [prompt] # Open the Task Manager to plan tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: ralpi runs whole task plans — there is no single-task or next-batch subcommand. To execute only part of a plan, edit the task file and remove/adjust the tasks first.
|
|
||||||
|
|
||||||
## Task File Location
|
|
||||||
|
|
||||||
Default: `README.md` in the current directory. Can be overridden with an explicit path (`@path`, `./path`, `*.md`, `*.yaml`, `*.yml`).
|
|
||||||
|
|
||||||
## Reflection Format
|
|
||||||
|
|
||||||
After completing a task, the task agent ends its response with a reflection block, which the extension parses and passes to downstream tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## REFLECTION
|
|
||||||
SUMMARY: [1-2 sentence description of what was accomplished]
|
|
||||||
FILES: [comma-separated list of files created or modified]
|
|
||||||
LEARNINGS:
|
|
||||||
- [key decision, pattern, or architectural choice]
|
|
||||||
- [important API or interface details]
|
|
||||||
- [anything downstream tasks need to know]
|
|
||||||
BLOCKERS: [any unresolved issues, or 'none']
|
|
||||||
```
|
|
||||||
274
src/diff.ts
Normal file
274
src/diff.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Reusable unified-diff engine: parses a diff into per-file +/− stats and
|
||||||
|
* filters out noise files (locks, build output, vendor, generated, media
|
||||||
|
* binaries) so review prompts feed the model only clean, review-relevant
|
||||||
|
* changes.
|
||||||
|
*
|
||||||
|
* Ported from @piex-dev/review's `EXCLUDED_PATTERNS` + `parseDiff` (MIT).
|
||||||
|
* Kept the excluded-files-not-totaled behavior that fixed the upstream
|
||||||
|
* double-count bug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Per-file diff stats. */
|
||||||
|
export interface FileDiff {
|
||||||
|
/** File path as it appears in the diff (`a/` path). */
|
||||||
|
path: string;
|
||||||
|
/** Number of added lines (excluding the `+++` header). */
|
||||||
|
linesAdded: number;
|
||||||
|
/** Number of removed lines (excluding the `---` header). */
|
||||||
|
linesRemoved: number;
|
||||||
|
/** File extension (empty when the path has none). */
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An excluded (noise) file with the reason it was filtered. */
|
||||||
|
export interface ExcludedFile extends FileDiff {
|
||||||
|
/** Why the file was excluded (e.g. "lockfile"). */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of parsing a unified diff. */
|
||||||
|
export interface DiffSummary {
|
||||||
|
/** Files kept in scope (review-relevant). */
|
||||||
|
files: FileDiff[];
|
||||||
|
/** Files filtered out as noise. */
|
||||||
|
excluded: ExcludedFile[];
|
||||||
|
/** Sum of added lines over included files only. */
|
||||||
|
totalAdded: number;
|
||||||
|
/** Sum of removed lines over included files only. */
|
||||||
|
totalRemoved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller-supplied overrides for the noise filter. */
|
||||||
|
export interface DiffOptions {
|
||||||
|
/** Additional exclusion regexes merged into EXCLUDED_PATTERNS. */
|
||||||
|
extraPatterns?: RegExp[];
|
||||||
|
/** Pathspec allowlist — files matching these stay in scope even if a
|
||||||
|
* default rule would exclude them. */
|
||||||
|
ignorePaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Noise-Filter Rules ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default noise-exclusion rules, ported from @piex-dev/review (MIT).
|
||||||
|
* Each entry is a regex tested against the file path plus a human-readable
|
||||||
|
* reason surfaced in the "Excluded Files" prompt section. */
|
||||||
|
export const EXCLUDED_PATTERNS: { pattern: RegExp; reason: string }[] = [
|
||||||
|
// Lockfiles
|
||||||
|
{ pattern: /(^|\/)package-lock\.json$/i, reason: "lockfile" },
|
||||||
|
{ pattern: /(^|\/)yarn\.lock$/i, reason: "lockfile" },
|
||||||
|
{ pattern: /(^|\/)pnpm-lock\.yaml$/i, reason: "lockfile" },
|
||||||
|
{ pattern: /(^|\/)Cargo\.lock$/i, reason: "lockfile" },
|
||||||
|
{ pattern: /(^|\/)Gemfile\.lock$/i, reason: "lockfile" },
|
||||||
|
{ pattern: /\.lock$/i, reason: "lockfile" },
|
||||||
|
// Minified assets
|
||||||
|
{ pattern: /\.min\.(js|css)$/i, reason: "minified asset" },
|
||||||
|
// Generated / tooling output
|
||||||
|
{ pattern: /\.generated\./i, reason: "generated file" },
|
||||||
|
{ pattern: /\.snap$/i, reason: "snapshot" },
|
||||||
|
{ pattern: /\.map$/i, reason: "source map" },
|
||||||
|
// Build output directories
|
||||||
|
{ pattern: /(^|\/)(dist|build|out|coverage)\//i, reason: "build output" },
|
||||||
|
// Dependency trees
|
||||||
|
{ pattern: /(^|\/)node_modules\//i, reason: "dependency" },
|
||||||
|
{ pattern: /(^|\/)vendor\//i, reason: "vendored dependency" },
|
||||||
|
// Image / font / binary extensions
|
||||||
|
{
|
||||||
|
pattern:
|
||||||
|
/\.(png|jpe?g|gif|webp|svg|ico|avif|bmp|woff2?|ttf|otf|eot|pdf|zip|tar|gz|mp[34]|wav|ogg|flac|wasm|bin|exe|dll|so|a|o|class|jar|pyc)$/i,
|
||||||
|
reason: "binary/media asset",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the exclusion reason for a file path, or undefined when the file is
|
||||||
|
* review-relevant. Extra caller-supplied patterns are merged into the default
|
||||||
|
* rule set.
|
||||||
|
*/
|
||||||
|
export function isExcluded(
|
||||||
|
fp: string,
|
||||||
|
extraPatterns?: RegExp[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const rule of EXCLUDED_PATTERNS) {
|
||||||
|
if (rule.pattern.test(fp)) return rule.reason;
|
||||||
|
}
|
||||||
|
if (extraPatterns) {
|
||||||
|
for (const p of extraPatterns) {
|
||||||
|
if (p.test(fp)) return "extra ignore pattern";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely compile user-supplied regex strings into RegExp objects. Invalid
|
||||||
|
* patterns (that don't compile) are skipped so a bad config value never
|
||||||
|
* crashes review prompt building.
|
||||||
|
*/
|
||||||
|
export function compileIgnorePatterns(patterns: string[]): RegExp[] {
|
||||||
|
const out: RegExp[] = [];
|
||||||
|
for (const p of patterns) {
|
||||||
|
if (!p) continue;
|
||||||
|
try {
|
||||||
|
out.push(new RegExp(p));
|
||||||
|
} catch {
|
||||||
|
// Skip malformed patterns silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chunking + Counting Helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Split a raw diff into per-file chunks, each starting at a `diff --git`
|
||||||
|
* line. The leading non-diff preamble (e.g. a `--stat` block) is dropped —
|
||||||
|
* per-file stats are derived from the patch chunks themselves. */
|
||||||
|
function chunkDiff(raw: string): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const lines = raw.split("\n");
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current: string[] = [];
|
||||||
|
let started = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("diff --git ")) {
|
||||||
|
if (started && current.length > 0) chunks.push(current.join("\n"));
|
||||||
|
current = [line];
|
||||||
|
started = true;
|
||||||
|
} else if (started) {
|
||||||
|
current.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (started && current.length > 0) chunks.push(current.join("\n"));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the `a/<path>` from a `diff --git a/… b/…` header. Returns null for
|
||||||
|
* malformed chunks that lack the a/… b/… header (guarded, never crashes). */
|
||||||
|
function chunkPath(chunk: string): string | null {
|
||||||
|
const m = chunk.match(/^diff --git a\/(.+?) b\//);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count added/removed lines in a chunk, excluding the `+++`/`---` headers. */
|
||||||
|
function countLines(chunk: string): { added: number; removed: number } {
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
for (const line of chunk.split("\n")) {
|
||||||
|
if (line.startsWith("+") && !line.startsWith("+++")) added++;
|
||||||
|
else if (line.startsWith("-") && !line.startsWith("---")) removed++;
|
||||||
|
}
|
||||||
|
return { added, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the file extension from a path (no ext → empty string). */
|
||||||
|
function getExt(fp: string): string {
|
||||||
|
const base = fp.split("/").pop() ?? "";
|
||||||
|
const idx = base.lastIndexOf(".");
|
||||||
|
return idx > 0 ? base.slice(idx + 1) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a git pathspec glob into a regex (supports `*`, `**`, `?`). */
|
||||||
|
function globToRegExp(glob: string): RegExp {
|
||||||
|
let re = "";
|
||||||
|
for (let i = 0; i < glob.length; i++) {
|
||||||
|
const c = glob[i];
|
||||||
|
if (c === "*") {
|
||||||
|
if (glob[i + 1] === "*") {
|
||||||
|
re += ".*";
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
re += "[^/]*";
|
||||||
|
}
|
||||||
|
} else if (c === "?") {
|
||||||
|
re += "[^/]";
|
||||||
|
} else if (c === ".") {
|
||||||
|
re += "\\.";
|
||||||
|
} else {
|
||||||
|
re += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RegExp(`^${re}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a file path matches a pathspec allowlist entry. */
|
||||||
|
function matchesPathspec(pathspec: string, fp: string): boolean {
|
||||||
|
const ps = pathspec.trim();
|
||||||
|
if (!ps) return false;
|
||||||
|
// Directory prefix: "tests/" or a bare dir name matches everything under it.
|
||||||
|
if (ps.endsWith("/") && fp.startsWith(ps)) return true;
|
||||||
|
if (ps.includes("*") || ps.includes("?")) return globToRegExp(ps).test(fp);
|
||||||
|
// Plain path — exact file or prefix directory.
|
||||||
|
if (fp === ps) return true;
|
||||||
|
if (fp.startsWith(ps + "/")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decide whether a file path is kept in scope or noise-excluded. */
|
||||||
|
function classify(
|
||||||
|
path: string,
|
||||||
|
opts?: DiffOptions,
|
||||||
|
): { kept: boolean; reason?: string } {
|
||||||
|
const reason = isExcluded(path, opts?.extraPatterns);
|
||||||
|
if (reason === undefined) return { kept: true };
|
||||||
|
// Excluded by a rule, but an ignorePaths allowlist can keep it in scope.
|
||||||
|
const keptByPathspec =
|
||||||
|
opts?.ignorePaths?.some((ps) => matchesPathspec(ps, path)) ?? false;
|
||||||
|
return keptByPathspec ? { kept: true } : { kept: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a unified diff into per-file +/− stats, splitting excluded (noise)
|
||||||
|
* files from included files. Totals are summed over included files only.
|
||||||
|
* Malformed chunks (no a/… b/… header) are skipped without crashing.
|
||||||
|
*/
|
||||||
|
export function parseDiff(raw: string, opts?: DiffOptions): DiffSummary {
|
||||||
|
const files: FileDiff[] = [];
|
||||||
|
const excluded: ExcludedFile[] = [];
|
||||||
|
let totalAdded = 0;
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
|
for (const chunk of chunkDiff(raw)) {
|
||||||
|
if (!chunk) continue;
|
||||||
|
const path = chunkPath(chunk);
|
||||||
|
if (path === null) continue; // malformed chunk — skip
|
||||||
|
const { added, removed } = countLines(chunk);
|
||||||
|
const base: FileDiff = {
|
||||||
|
path,
|
||||||
|
linesAdded: added,
|
||||||
|
linesRemoved: removed,
|
||||||
|
ext: getExt(path),
|
||||||
|
};
|
||||||
|
const decision = classify(path, opts);
|
||||||
|
if (decision.kept) {
|
||||||
|
files.push(base);
|
||||||
|
totalAdded += added;
|
||||||
|
totalRemoved += removed;
|
||||||
|
} else if (decision.reason) {
|
||||||
|
excluded.push({ ...base, reason: decision.reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, excluded, totalAdded, totalRemoved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the diff re-emitted with excluded (noise) file chunks removed, so an
|
||||||
|
* inlined review diff never contains filtered content. The stat preamble is
|
||||||
|
* dropped — the per-file summary table carries that information. Empty string
|
||||||
|
* when every changed file is noise.
|
||||||
|
*/
|
||||||
|
export function filterNoise(raw: string, opts?: DiffOptions): string {
|
||||||
|
const kept: string[] = [];
|
||||||
|
for (const chunk of chunkDiff(raw)) {
|
||||||
|
if (!chunk) continue;
|
||||||
|
const path = chunkPath(chunk);
|
||||||
|
if (path === null) continue;
|
||||||
|
const decision = classify(path, opts);
|
||||||
|
if (decision.kept) kept.push(chunk);
|
||||||
|
}
|
||||||
|
return kept.join("\n");
|
||||||
|
}
|
||||||
302
src/executor.ts
302
src/executor.ts
@@ -12,6 +12,7 @@ import type { ProgressTracker } from "./progress";
|
|||||||
import type {
|
import type {
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
ModelRuntime,
|
ModelRuntime,
|
||||||
|
AgentSessionEvent,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
buildTaskPrompt,
|
buildTaskPrompt,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
buildConflictResolutionPrompt,
|
buildConflictResolutionPrompt,
|
||||||
MAX_DIFF_BYTES,
|
MAX_DIFF_BYTES,
|
||||||
} from "./prompts";
|
} from "./prompts";
|
||||||
|
import { compileIgnorePatterns } from "./diff";
|
||||||
import { extractReflection } from "./reflection";
|
import { extractReflection } from "./reflection";
|
||||||
import {
|
import {
|
||||||
extractReview,
|
extractReview,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
ensureDir,
|
ensureDir,
|
||||||
captureGitCommits,
|
captureGitCommits,
|
||||||
captureGitHead,
|
captureGitHead,
|
||||||
|
canComputeRange,
|
||||||
getCommitRangeDiff,
|
getCommitRangeDiff,
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
getGitStatusPorcelain,
|
getGitStatusPorcelain,
|
||||||
@@ -54,6 +57,28 @@ import {
|
|||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { updateTaskInFile } from "./parser";
|
import { updateTaskInFile } from "./parser";
|
||||||
|
|
||||||
|
// ─── Stream Forwarder (verbose chat style) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level callback for verbose per-event chat streaming. Set by
|
||||||
|
* `index.ts` at extension startup via {@link setStreamForwarder} when the
|
||||||
|
* config's `execution.chatStyle` is "verbose". `runTask`'s event callback
|
||||||
|
* checks this and forwards each tool_execution_start/end + message_end as
|
||||||
|
* its own chat message — the piolium/pygienium per-event stream. When null
|
||||||
|
* (compact mode, the default), only the completion message with its
|
||||||
|
* expandable tool-call tree shows.
|
||||||
|
*/
|
||||||
|
let _streamForwarder:
|
||||||
|
| ((phase: string, event: AgentSessionEvent) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
/** Register the verbose stream forwarder (called by index.ts at startup). */
|
||||||
|
export function setStreamForwarder(
|
||||||
|
fn: ((phase: string, event: AgentSessionEvent) => void) | null,
|
||||||
|
): void {
|
||||||
|
_streamForwarder = fn;
|
||||||
|
}
|
||||||
|
|
||||||
/** Optional callback to post a progress message into the chat history. */
|
/** Optional callback to post a progress message into the chat history. */
|
||||||
export type SendChatMessage = (
|
export type SendChatMessage = (
|
||||||
content: string,
|
content: string,
|
||||||
@@ -220,6 +245,13 @@ export async function runTask(
|
|||||||
/** Review feedback from a rejected review — injected when re-executing
|
/** Review feedback from a rejected review — injected when re-executing
|
||||||
* a task in review-gated mode so the agent knows what to fix. */
|
* a task in review-gated mode so the agent knows what to fix. */
|
||||||
reviewFeedback?: ReviewResult,
|
reviewFeedback?: ReviewResult,
|
||||||
|
/** Session JSONL from a prior interrupted run of this task. When set and
|
||||||
|
* readable, the agent session reopens it and continues from the prior
|
||||||
|
* conversation instead of starting fresh. */
|
||||||
|
resumeSessionFile?: string,
|
||||||
|
/** Called with the session file path as soon as the agent session is
|
||||||
|
* created, so the caller can persist it for a later resume. */
|
||||||
|
onSessionFile?: (sessionFile: string) => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
@@ -229,6 +261,12 @@ export async function runTask(
|
|||||||
outputPreview?: string;
|
outputPreview?: string;
|
||||||
commitMessages?: string[];
|
commitMessages?: string[];
|
||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
|
/** Path to the JSONL session file backing this run (for resume). */
|
||||||
|
sessionFile?: string;
|
||||||
|
/** True when a resume was requested but the session could not be opened
|
||||||
|
* from the file — the caller should clear the stored session file so
|
||||||
|
* retries start fresh. */
|
||||||
|
resumeFailed?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
@@ -334,6 +372,10 @@ export async function runTask(
|
|||||||
projectDir,
|
projectDir,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
(event) => {
|
(event) => {
|
||||||
|
// Forward to the verbose stream when enabled.
|
||||||
|
if (_streamForwarder && config.execution.chatStyle === "verbose") {
|
||||||
|
_streamForwarder(`${task.id} · ${task.title}`, event);
|
||||||
|
}
|
||||||
if (event.type === "tool_execution_start") {
|
if (event.type === "tool_execution_start") {
|
||||||
const label = formatToolArg(event.toolName, event.args);
|
const label = formatToolArg(event.toolName, event.args);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
@@ -356,6 +398,9 @@ export async function runTask(
|
|||||||
config.thinkingLevel,
|
config.thinkingLevel,
|
||||||
false, // noSkills — task sessions need skills
|
false, // noSkills — task sessions need skills
|
||||||
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
|
resumeSessionFile,
|
||||||
|
onSessionFile,
|
||||||
);
|
);
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
const durationMs = Date.now() - startMs;
|
||||||
@@ -380,6 +425,8 @@ export async function runTask(
|
|||||||
success: false,
|
success: false,
|
||||||
error: output.error,
|
error: output.error,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
sessionFile: output.sessionFile,
|
||||||
|
resumeFailed: output.resumeFailed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +457,7 @@ export async function runTask(
|
|||||||
outputPreview,
|
outputPreview,
|
||||||
commitMessages,
|
commitMessages,
|
||||||
commitSummary,
|
commitSummary,
|
||||||
|
sessionFile: output.sessionFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,10 +804,20 @@ async function executeTask(
|
|||||||
conflicts?: BatchConflict[],
|
conflicts?: BatchConflict[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Model failover: when a provider/API is down, cycle through available models.
|
// Model failover: when a provider/API is down, cycle through available models.
|
||||||
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
// Pi's built-in retry (via SettingsManager) handles transient HTTP errors
|
||||||
// exponential backoff within each model. Ralpi only handles model cycling.
|
// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on
|
||||||
|
// top: (1) reattempt the SAME model up to `maxSameModelAttempts` times — a
|
||||||
|
// sustained provider hiccup can exhaust pi's in-call retries mid-session,
|
||||||
|
// and flapping to a different model on the first hard failure throws away
|
||||||
|
// model-specific context; (2) once same-model retries are exhausted, cycle
|
||||||
|
// to the next model in the round-robin pool.
|
||||||
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
||||||
|
const maxSameModelAttempts = Math.max(
|
||||||
|
1,
|
||||||
|
config.execution.maxSameModelAttempts,
|
||||||
|
);
|
||||||
let modelAttempt = 0;
|
let modelAttempt = 0;
|
||||||
|
let sameModelAttempt = 0;
|
||||||
// Resolve implModel from config (used in sequential mode when no round-robin assignment).
|
// Resolve implModel from config (used in sequential mode when no round-robin assignment).
|
||||||
// In parallel mode, the round-robin assignedModel takes precedence.
|
// In parallel mode, the round-robin assignedModel takes precedence.
|
||||||
const implModel = resolveModelSpec(
|
const implModel = resolveModelSpec(
|
||||||
@@ -786,13 +844,16 @@ async function executeTask(
|
|||||||
: null;
|
: null;
|
||||||
const worktreeDir = wt?.dir ?? projectDir;
|
const worktreeDir = wt?.dir ?? projectDir;
|
||||||
|
|
||||||
|
// Session file from a prior interrupted run of this task. The first
|
||||||
|
// attempt reopens it so the agent continues with its prior conversation
|
||||||
|
// (tool calls, findings) instead of restarting from scratch; failover
|
||||||
|
// retries start fresh.
|
||||||
|
const resumeSessionFile = progress.getSessionFile(task.id);
|
||||||
|
|
||||||
while (modelAttempt < maxModelAttempts) {
|
while (modelAttempt < maxModelAttempts) {
|
||||||
// On subsequent model attempts, advance to the next model.
|
// Model advancement happens in the cycling branch below (not here) so a
|
||||||
// Uses advance() instead of assign() so we don't get stuck on
|
// same-model retry `continue` doesn't re-advance and accidentally swap
|
||||||
// the same freed slot when the current model is down.
|
// models mid-retry. The first model uses `currentModel` set above.
|
||||||
if (modelAttempt > 0 && roundRobin) {
|
|
||||||
currentModel = roundRobin.advance(task.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark as in progress
|
// Mark as in progress
|
||||||
@@ -849,6 +910,11 @@ async function executeTask(
|
|||||||
currentModel,
|
currentModel,
|
||||||
batchRender,
|
batchRender,
|
||||||
priorReview,
|
priorReview,
|
||||||
|
modelAttempt === 0 && sameModelAttempt === 0
|
||||||
|
? resumeSessionFile
|
||||||
|
: undefined,
|
||||||
|
(sessionFile) =>
|
||||||
|
progress.setSessionFile(task.id, sessionFile),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -897,19 +963,43 @@ async function executeTask(
|
|||||||
// baseRef was captured before runTask (above). Each review iteration
|
// baseRef was captured before runTask (above). Each review iteration
|
||||||
// diffs the range baseRef..HEAD — the complete task output including
|
// diffs the range baseRef..HEAD — the complete task output including
|
||||||
// all fix attempts. On re-execution the same baseRef is reused.
|
// all fix attempts. On re-execution the same baseRef is reused.
|
||||||
|
// A FAILED range computation (broken/stale base ref, git error) is
|
||||||
|
// logged as a distinct warning and is never treated as a clean,
|
||||||
|
// verified task — only a GENUINE "no changes" skips review.
|
||||||
|
// Accumulated rejected reviews from earlier passes — injected
|
||||||
|
// into the next review prompt so the reviewer sees prior
|
||||||
|
// findings and can verify they were addressed.
|
||||||
|
const priorReviews: ReviewResult[] = [];
|
||||||
while (true) {
|
while (true) {
|
||||||
const reviewInfo = baseRef
|
if (!baseRef) {
|
||||||
? getCommitRangeDiff(worktreeDir, baseRef)
|
|
||||||
: null;
|
|
||||||
if (!reviewInfo || !reviewInfo.diff) {
|
|
||||||
const reason = !baseRef
|
|
||||||
? "could not capture base ref before execution"
|
|
||||||
: "no changes found between base and HEAD";
|
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
`~ review for ${task.id} · ${task.title} — skipping review (${reason})`,
|
`~ review for ${task.id} · ${task.title} — diff could not be computed (could not capture base ref before execution)`,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Cheap guard mirroring canCompareToBase: if the captured base ref no
|
||||||
|
// longer resolves (stale/broken worktree ref), warn explicitly and
|
||||||
|
// never treat the task as review-verified.
|
||||||
|
if (!canComputeRange(worktreeDir, baseRef)) {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ review for ${task.id} · ${task.title} — diff could not be computed (base ref ${baseRef} no longer resolves)`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const rangeDiff = getCommitRangeDiff(worktreeDir, baseRef);
|
||||||
|
if (rangeDiff.kind === "error") {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ review for ${task.id} · ${task.title} — diff could not be computed (${rangeDiff.error})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (rangeDiff.kind === "no-changes") {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ review for ${task.id} · ${task.title} — skipping review (no changes found between base and HEAD)`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const reviewInfo = rangeDiff;
|
||||||
|
|
||||||
const reviewPrompt = buildReviewPrompt(
|
const reviewPrompt = buildReviewPrompt(
|
||||||
task,
|
task,
|
||||||
@@ -917,7 +1007,17 @@ async function executeTask(
|
|||||||
reviewInfo.hash,
|
reviewInfo.hash,
|
||||||
reviewInfo.subject,
|
reviewInfo.subject,
|
||||||
reviewInfo.diff,
|
reviewInfo.diff,
|
||||||
config.prompts.projectContext,
|
{
|
||||||
|
projectContext: config.prompts.projectContext,
|
||||||
|
focus: config.prompts.reviewFocus,
|
||||||
|
priorReviews,
|
||||||
|
diffOptions: {
|
||||||
|
extraPatterns: compileIgnorePatterns(
|
||||||
|
config.review.extraIgnorePatterns,
|
||||||
|
),
|
||||||
|
ignorePaths: config.review.ignorePaths,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const reviewModel = resolveFollowUpModel(
|
const reviewModel = resolveFollowUpModel(
|
||||||
@@ -939,6 +1039,7 @@ async function executeTask(
|
|||||||
`review-${task.id}`,
|
`review-${task.id}`,
|
||||||
config.execution.reviewTimeoutMs,
|
config.execution.reviewTimeoutMs,
|
||||||
reviewModels,
|
reviewModels,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!reviewResult.success) {
|
if (!reviewResult.success) {
|
||||||
@@ -1032,6 +1133,9 @@ async function executeTask(
|
|||||||
break; // changes already committed — merge proceeds
|
break; // changes already committed — merge proceeds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate the rejected review so the next review pass
|
||||||
|
// sees prior findings and can verify they were addressed.
|
||||||
|
if (review) priorReviews.push(review);
|
||||||
attempt++;
|
attempt++;
|
||||||
reviewRetries++;
|
reviewRetries++;
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
@@ -1049,19 +1153,33 @@ async function executeTask(
|
|||||||
fixAttempt++
|
fixAttempt++
|
||||||
) {
|
) {
|
||||||
const fixModel = fixModels[fixAttempt];
|
const fixModel = fixModels[fixAttempt];
|
||||||
fixResult = await runTask(
|
let fixSameAttempt = 0;
|
||||||
task,
|
// Reattempt on the same model before cycling, matching the main
|
||||||
project,
|
// task loop's behavior.
|
||||||
config,
|
for (;;) {
|
||||||
depReflections,
|
fixResult = await runTask(
|
||||||
ctx,
|
task,
|
||||||
sendChatMessage,
|
project,
|
||||||
worktreeDir,
|
config,
|
||||||
parallelState,
|
depReflections,
|
||||||
fixModel,
|
ctx,
|
||||||
batchRender,
|
sendChatMessage,
|
||||||
review ?? undefined,
|
worktreeDir,
|
||||||
);
|
parallelState,
|
||||||
|
fixModel,
|
||||||
|
batchRender,
|
||||||
|
review ?? undefined,
|
||||||
|
);
|
||||||
|
if (fixResult.success) break;
|
||||||
|
if (fixSameAttempt < maxSameModelAttempts - 1) {
|
||||||
|
fixSameAttempt++;
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ re-execution for ${task.id} · ${task.title} — reattempting model ${fixAttempt + 1}/${fixModels.length} (${fixSameAttempt + 1}/${maxSameModelAttempts}, previous: ${fixResult.error})`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break; // same-model retries exhausted
|
||||||
|
}
|
||||||
if (fixResult.success) break;
|
if (fixResult.success) break;
|
||||||
// Connection/error failover — try the next model.
|
// Connection/error failover — try the next model.
|
||||||
if (fixAttempt < fixModels.length - 1) {
|
if (fixAttempt < fixModels.length - 1) {
|
||||||
@@ -1214,6 +1332,7 @@ async function executeTask(
|
|||||||
finalCommitSummary,
|
finalCommitSummary,
|
||||||
finalReview,
|
finalReview,
|
||||||
reviewRetries,
|
reviewRetries,
|
||||||
|
result.sessionFile,
|
||||||
);
|
);
|
||||||
// Auto-update the PRD source file checkbox
|
// Auto-update the PRD source file checkbox
|
||||||
try {
|
try {
|
||||||
@@ -1226,9 +1345,28 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent session failed (provider error).
|
// Agent session failed (provider error).
|
||||||
// Pi's built-in retry already exhausted for this model. Cycle to the next.
|
// Pi's built-in in-call retry already exhausted for this attempt.
|
||||||
|
// A resumed session that couldn't be opened (corrupt/missing
|
||||||
|
// JSONL) must not be retried — forget it so later attempts (and
|
||||||
|
// future resumes) start fresh.
|
||||||
|
if (result.resumeFailed) {
|
||||||
|
progress.setSessionFile(task.id, undefined);
|
||||||
|
}
|
||||||
|
// Reattempt on the SAME model a few more times before cycling — a
|
||||||
|
// transient outage can outlast pi's per-prompt backoff window.
|
||||||
|
sameModelAttempt++;
|
||||||
|
if (sameModelAttempt < maxSameModelAttempts) {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ ${task.id} · ${task.title} — reattempting model ${modelAttempt + 1}/${maxModelAttempts} (${sameModelAttempt + 1}/${maxSameModelAttempts}, previous: ${result.error})`,
|
||||||
|
);
|
||||||
|
continue; // same model, fresh session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-model retries exhausted — cycle to the next model (if any).
|
||||||
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
||||||
modelAttempt++;
|
modelAttempt++;
|
||||||
|
sameModelAttempt = 0;
|
||||||
|
currentModel = roundRobin.advance(task.id);
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
`~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
`~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
||||||
);
|
);
|
||||||
@@ -1346,6 +1484,8 @@ async function runFollowUpSession(
|
|||||||
widgetKeySuffix: string,
|
widgetKeySuffix: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
models: unknown[],
|
models: unknown[],
|
||||||
|
/** Inactivity timeout for the session (see runAgentSession). */
|
||||||
|
inactivityTimeoutMs = 0,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
result: Awaited<ReturnType<typeof runAgentSession>>;
|
result: Awaited<ReturnType<typeof runAgentSession>>;
|
||||||
toolCalls: ToolCallEntry[];
|
toolCalls: ToolCallEntry[];
|
||||||
@@ -1417,28 +1557,44 @@ async function runFollowUpSession(
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof runAgentSession>> | undefined;
|
let result: Awaited<ReturnType<typeof runAgentSession>> | undefined;
|
||||||
|
const maxSameModelAttempts = Math.max(
|
||||||
|
1,
|
||||||
|
config.execution.maxSameModelAttempts,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
for (let attempt = 0; attempt < models.length; attempt++) {
|
for (let attempt = 0; attempt < models.length; attempt++) {
|
||||||
const model = models[attempt];
|
const model = models[attempt];
|
||||||
result = await runAgentSession(
|
// Reattempt on the same model before cycling — matches the main task
|
||||||
prompt,
|
// loop. Clear partial tool calls between failed attempts so the
|
||||||
projectDir,
|
// widget reflects only the successful (or final) attempt.
|
||||||
timeoutMs,
|
for (let same = 0; same < maxSameModelAttempts; same++) {
|
||||||
(event) => {
|
result = await runAgentSession(
|
||||||
if (event.type === "tool_execution_start") {
|
prompt,
|
||||||
const label = formatToolArg(event.toolName, event.args);
|
projectDir,
|
||||||
toolCalls.push({ name: event.toolName, label });
|
timeoutMs,
|
||||||
requestRender();
|
(event) => {
|
||||||
}
|
if (event.type === "tool_execution_start") {
|
||||||
},
|
const label = formatToolArg(event.toolName, event.args);
|
||||||
undefined,
|
toolCalls.push({ name: event.toolName, label });
|
||||||
model,
|
requestRender();
|
||||||
config.thinkingLevel,
|
}
|
||||||
false, // noSkills=false — follow-up sessions load skills too
|
},
|
||||||
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
undefined,
|
||||||
);
|
model,
|
||||||
|
config.thinkingLevel,
|
||||||
|
false, // noSkills=false — follow-up sessions load skills too
|
||||||
|
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
||||||
|
inactivityTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success) break;
|
if (result.success) break;
|
||||||
|
if (same < maxSameModelAttempts - 1) {
|
||||||
|
toolCalls.length = 0;
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result!.success) break;
|
||||||
|
|
||||||
// If there's a next model to try, cycle; otherwise give up.
|
// If there's a next model to try, cycle; otherwise give up.
|
||||||
if (attempt < models.length - 1) {
|
if (attempt < models.length - 1) {
|
||||||
@@ -1575,6 +1731,7 @@ async function runCommitSession(
|
|||||||
`commit-${task.id}`,
|
`commit-${task.id}`,
|
||||||
config.execution.commitTimeoutMs,
|
config.execution.commitTimeoutMs,
|
||||||
commitModels,
|
commitModels,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (commitResult.success) {
|
if (commitResult.success) {
|
||||||
@@ -1672,6 +1829,52 @@ async function resolveConflictsSession(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge failed but produced no unmerged paths — no real conflicts to
|
||||||
|
// resolve. This can happen when the branch tip was already merged by the
|
||||||
|
// first attempt (mergeWorktree) before it aborted, or when the merge
|
||||||
|
// fails for a non-conflict reason (dirty index, stale ref). Don't spawn
|
||||||
|
// an agent session for zero conflicts — abort or complete and finish.
|
||||||
|
if (attempt.conflicts.length === 0) {
|
||||||
|
// Try to complete whatever merge state exists; if there's nothing to
|
||||||
|
// commit, abort to leave the working tree clean.
|
||||||
|
if (completeMerge(projectDir)) {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`✓ conflicts auto-resolved for ${task.id} · ${task.title}`,
|
||||||
|
);
|
||||||
|
removeWorktree(projectDir, worktree);
|
||||||
|
progress.markCompleted(
|
||||||
|
task.id,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortMerge(projectDir);
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ conflict resolution for ${task.id} · ${task.title} — merge produced no conflicts but could not be completed`,
|
||||||
|
);
|
||||||
|
progress.markFailed(
|
||||||
|
task.id,
|
||||||
|
`Merge of ${branch} produced no conflicts but could not be completed`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Conflicts exist — spawn a resolution agent session.
|
// Conflicts exist — spawn a resolution agent session.
|
||||||
const prompt = buildConflictResolutionPrompt(
|
const prompt = buildConflictResolutionPrompt(
|
||||||
task,
|
task,
|
||||||
@@ -1701,6 +1904,7 @@ async function resolveConflictsSession(
|
|||||||
`resolve-${task.id}`,
|
`resolve-${task.id}`,
|
||||||
config.execution.commitTimeoutMs,
|
config.execution.commitTimeoutMs,
|
||||||
models,
|
models,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -185,11 +185,13 @@ export class ProgressTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as in progress */
|
/** Mark a task as in progress */
|
||||||
markInProgress(taskId: string): void {
|
markInProgress(taskId: string, sessionFile?: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "in_progress";
|
prd.tasks[taskId].status = "in_progress";
|
||||||
prd.tasks[taskId].startedAt = new Date().toISOString();
|
prd.tasks[taskId].startedAt = new Date().toISOString();
|
||||||
|
if (sessionFile !== undefined)
|
||||||
|
prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +206,7 @@ export class ProgressTracker {
|
|||||||
commitSummary?: string,
|
commitSummary?: string,
|
||||||
review?: ReviewResult,
|
review?: ReviewResult,
|
||||||
reviewRetries?: number,
|
reviewRetries?: number,
|
||||||
|
sessionFile?: string,
|
||||||
): void {
|
): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
@@ -218,6 +221,7 @@ export class ProgressTracker {
|
|||||||
if (review) prd.tasks[taskId].review = review;
|
if (review) prd.tasks[taskId].review = review;
|
||||||
if (reviewRetries !== undefined)
|
if (reviewRetries !== undefined)
|
||||||
prd.tasks[taskId].reviewRetries = reviewRetries;
|
prd.tasks[taskId].reviewRetries = reviewRetries;
|
||||||
|
if (sessionFile !== undefined) prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +240,22 @@ export class ProgressTracker {
|
|||||||
return prd.tasks[taskId]?.status ?? "pending";
|
return prd.tasks[taskId]?.status ?? "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the persisted session file path for a task (for resume), if any. */
|
||||||
|
getSessionFile(taskId: string): string | undefined {
|
||||||
|
const prd = this.getPRD();
|
||||||
|
return prd.tasks[taskId]?.sessionFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the session file path for a task without changing its status.
|
||||||
|
* Called as soon as an agent session is created so an interrupted run
|
||||||
|
* can be resumed from the JSONL history. Pass undefined to clear. */
|
||||||
|
setSessionFile(taskId: string, sessionFile: string | undefined): void {
|
||||||
|
const prd = this.getPRD();
|
||||||
|
this.ensureTask(prd, taskId);
|
||||||
|
prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
/** Get IDs of all completed tasks */
|
/** Get IDs of all completed tasks */
|
||||||
getCompletedTaskIds(): string[] {
|
getCompletedTaskIds(): string[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
|
|||||||
235
src/prompts.ts
235
src/prompts.ts
@@ -1,27 +1,40 @@
|
|||||||
import type { Task, Project, Reflection, ReviewResult } from "./types";
|
import type { Task, Project, Reflection, ReviewResult } from "./types";
|
||||||
import { readTaskSpec } from "./parser";
|
import { readTaskSpec } from "./parser";
|
||||||
|
import {
|
||||||
|
parseDiff,
|
||||||
|
filterNoise,
|
||||||
|
type DiffSummary,
|
||||||
|
type DiffOptions,
|
||||||
|
} from "./diff";
|
||||||
|
|
||||||
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
|
/** Maximum bytes of an inlined review diff before we stop inlining it and
|
||||||
* Diffs larger than this are truncated to avoid blowing past the model's
|
* instead list the changed files + tell the model to `read` them.
|
||||||
* context window. The agent can always run `git show HEAD` itself to
|
* Diffs larger than this are never byte-truncated into a review prompt —
|
||||||
* inspect the full diff when it needs more detail.
|
* truncation loses the middle of a large diff, so the file-list + read
|
||||||
|
* instruction is strictly better.
|
||||||
*
|
*
|
||||||
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
|
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
|
||||||
* context window once system-prompt overhead is accounted for. */
|
* context window once system-prompt overhead is accounted for. */
|
||||||
export const MAX_DIFF_BYTES = 50_000;
|
export const MAX_DIFF_BYTES = 50_000;
|
||||||
|
|
||||||
/**
|
/** Max included files before an oversized diff is replaced by a read
|
||||||
* Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated.
|
* instruction rather than inlined. */
|
||||||
*/
|
const MAX_REVIEW_FILES = 20;
|
||||||
function truncateDiff(diff: string): string {
|
|
||||||
if (diff.length <= MAX_DIFF_BYTES) return diff;
|
/** Optional knobs for the review prompt builders. */
|
||||||
const omitted = diff.length - MAX_DIFF_BYTES;
|
export interface ReviewPromptOptions {
|
||||||
return (
|
/** Extra context injected into the prompt (config.prompts.projectContext). */
|
||||||
diff.slice(0, MAX_DIFF_BYTES) +
|
projectContext?: string;
|
||||||
"\n\n... (diff truncated: omitted " +
|
/** Per-review custom focus/instructions (config.prompts.reviewFocus). */
|
||||||
omitted.toLocaleString() +
|
focus?: string;
|
||||||
" bytes; run `git show HEAD` to view the full diff)"
|
/** Noise-filter overrides (config.review.*). */
|
||||||
);
|
diffOptions?: DiffOptions;
|
||||||
|
/** Prior review results from earlier passes in a review-gated loop.
|
||||||
|
* Each rejected review is injected into the next review prompt so the
|
||||||
|
* reviewer can verify prior findings were addressed and catch new
|
||||||
|
* regressions introduced by the fix attempt — instead of re-reviewing
|
||||||
|
* from scratch. */
|
||||||
|
priorReviews?: ReviewResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
||||||
@@ -201,7 +214,7 @@ export function buildReviewPrompt(
|
|||||||
commitHash: string,
|
commitHash: string,
|
||||||
commitSubject: string,
|
commitSubject: string,
|
||||||
commitDiff: string,
|
commitDiff: string,
|
||||||
projectContext?: string,
|
opts: ReviewPromptOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
@@ -236,17 +249,38 @@ export function buildReviewPrompt(
|
|||||||
parts.push("## Commit Under Review");
|
parts.push("## Commit Under Review");
|
||||||
parts.push(`Commit: ${commitHash} — ${commitSubject}`);
|
parts.push(`Commit: ${commitHash} — ${commitSubject}`);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push("### Diff");
|
|
||||||
parts.push("```diff");
|
// ── Changed-Files Summary + Exclusions (noise-filtered scope) ──
|
||||||
parts.push(truncateDiff(commitDiff));
|
|
||||||
parts.push("```");
|
const summary = parseDiff(commitDiff, opts.diffOptions);
|
||||||
|
const filtered = filterNoise(commitDiff, opts.diffOptions);
|
||||||
|
parts.push(buildFileSummaryTable(summary));
|
||||||
|
const excluded = renderExcludedFiles(summary);
|
||||||
|
if (excluded) parts.push(excluded);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Diff (inline, or file-list + read instruction when oversized) ──
|
||||||
|
|
||||||
|
parts.push(renderDiffSection(summary, filtered, "### Diff"));
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Prior Review History (review-gated re-review) ──
|
||||||
|
|
||||||
|
parts.push(renderPriorReviews(opts.priorReviews ?? []));
|
||||||
|
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
|
||||||
|
|
||||||
|
// ── Custom Review Focus ──
|
||||||
|
if (opts.focus) {
|
||||||
|
parts.push("## Custom Review Focus");
|
||||||
|
parts.push(opts.focus);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project Context ──
|
// ── Project Context ──
|
||||||
|
|
||||||
if (projectContext) {
|
if (opts.projectContext) {
|
||||||
parts.push("## Additional Context");
|
parts.push("## Additional Context");
|
||||||
parts.push(projectContext);
|
parts.push(opts.projectContext);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +288,7 @@ export function buildReviewPrompt(
|
|||||||
|
|
||||||
parts.push("## Review Instructions");
|
parts.push("## Review Instructions");
|
||||||
parts.push(
|
parts.push(
|
||||||
"Review the commit above against the task description. Check for:",
|
"Review the changes above against the task description. Check for:",
|
||||||
);
|
);
|
||||||
parts.push(...reviewInstructions());
|
parts.push(...reviewInstructions());
|
||||||
parts.push("");
|
parts.push("");
|
||||||
@@ -279,7 +313,7 @@ export function buildReviewPromptUncommitted(
|
|||||||
project: Project,
|
project: Project,
|
||||||
status: string,
|
status: string,
|
||||||
diff: string,
|
diff: string,
|
||||||
projectContext?: string,
|
opts: ReviewPromptOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
@@ -321,17 +355,40 @@ export function buildReviewPromptUncommitted(
|
|||||||
parts.push(status || "(no status output)");
|
parts.push(status || "(no status output)");
|
||||||
parts.push("```");
|
parts.push("```");
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push("### Current Tracked Diff (git diff)");
|
|
||||||
parts.push("```diff");
|
// ── Changed-Files Summary + Exclusions (noise-filtered scope) ──
|
||||||
parts.push(truncateDiff(diff) || "(no tracked diff output)");
|
|
||||||
parts.push("```");
|
const summary = parseDiff(diff, opts.diffOptions);
|
||||||
|
const filtered = filterNoise(diff, opts.diffOptions);
|
||||||
|
parts.push(buildFileSummaryTable(summary));
|
||||||
|
const excluded = renderExcludedFiles(summary);
|
||||||
|
if (excluded) parts.push(excluded);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Diff (inline, or file-list + read instruction when oversized) ──
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Prior Review History (review-gated re-review) ──
|
||||||
|
|
||||||
|
parts.push(renderPriorReviews(opts.priorReviews ?? []));
|
||||||
|
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
|
||||||
|
|
||||||
|
// ── Custom Review Focus ──
|
||||||
|
|
||||||
|
if (opts.focus) {
|
||||||
|
parts.push("## Custom Review Focus");
|
||||||
|
parts.push(opts.focus);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project Context ──
|
// ── Project Context ──
|
||||||
|
|
||||||
if (projectContext) {
|
if (opts.projectContext) {
|
||||||
parts.push("## Additional Context");
|
parts.push("## Additional Context");
|
||||||
parts.push(projectContext);
|
parts.push(opts.projectContext);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +411,117 @@ export function buildReviewPromptUncommitted(
|
|||||||
|
|
||||||
// ─── Shared Review Prompt Helpers ───────────────────────────────────────────
|
// ─── Shared Review Prompt Helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether an oversized/wide diff should be replaced by a file-list + read
|
||||||
|
* instruction instead of being inlined. Thresholds: cleaned diff over
|
||||||
|
* MAX_DIFF_BYTES, or more than MAX_REVIEW_FILES included files. */
|
||||||
|
function shouldSkipInline(summary: DiffSummary, filteredLength: number): boolean {
|
||||||
|
return (
|
||||||
|
filteredLength > MAX_DIFF_BYTES || summary.files.length > MAX_REVIEW_FILES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a per-file +/− summary Markdown table (with type column and a total
|
||||||
|
* line) from a parsed diff. Handles the empty/all-noise diff gracefully — an
|
||||||
|
* empty table with zero totals, no crash.
|
||||||
|
*/
|
||||||
|
function buildFileSummaryTable(summary: DiffSummary): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("### Changed Files");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("| File | +/− | Type |");
|
||||||
|
lines.push("|------|-----|------|");
|
||||||
|
if (summary.files.length === 0) {
|
||||||
|
lines.push("| _(no included changes)_ | — | — |");
|
||||||
|
} else {
|
||||||
|
for (const f of summary.files) {
|
||||||
|
lines.push(
|
||||||
|
`| \`${f.path}\` | +${f.linesAdded}/-${f.linesRemoved} | ${f.ext || "—"} |`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(`| **Total** | **+${summary.totalAdded}/-${summary.totalRemoved}** | |`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `### Excluded Files (n)` bullet list (path, +/− counts, reason).
|
||||||
|
* Returns an empty string when there are no exclusions so callers omit the
|
||||||
|
* section entirely (no empty heading).
|
||||||
|
*/
|
||||||
|
function renderExcludedFiles(summary: DiffSummary): string {
|
||||||
|
if (summary.excluded.length === 0) return "";
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`### Excluded Files (${summary.excluded.length})`);
|
||||||
|
lines.push("");
|
||||||
|
for (const f of summary.excluded) {
|
||||||
|
lines.push(
|
||||||
|
`- \`${f.path}\` (+${f.linesAdded}/-${f.linesRemoved}) — ${f.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the diff section of a review prompt. Under the threshold, inline the
|
||||||
|
* noise-filtered diff. Over the threshold (size or file count), emit a
|
||||||
|
* file-list + read-instruction notice and never byte-truncate the diff.
|
||||||
|
*/
|
||||||
|
function renderDiffSection(
|
||||||
|
summary: DiffSummary,
|
||||||
|
filtered: string,
|
||||||
|
heading: string,
|
||||||
|
): string {
|
||||||
|
if (shouldSkipInline(summary, filtered.length)) {
|
||||||
|
return `${heading} — _Diff too large (${filtered.length.toLocaleString()} chars, ${summary.files.length} files). Use \`read\` to inspect the changed files._`;
|
||||||
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(heading);
|
||||||
|
lines.push("```diff");
|
||||||
|
lines.push(filtered || "(no included changes)");
|
||||||
|
lines.push("```");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a "Prior Review History" section from earlier rejected reviews.
|
||||||
|
* Returns an empty string when there are no prior reviews so callers omit
|
||||||
|
* the section entirely.
|
||||||
|
*
|
||||||
|
* Each prior review's verdict, summary, and findings are listed so the
|
||||||
|
* reviewer can verify the developer addressed them and watch for new
|
||||||
|
* regressions — instead of re-reviewing from scratch on each pass.
|
||||||
|
*/
|
||||||
|
function renderPriorReviews(priorReviews: ReviewResult[]): string {
|
||||||
|
if (priorReviews.length === 0) return "";
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("## Prior Review History");
|
||||||
|
lines.push(
|
||||||
|
"Previous review pass(es) rejected this task. Verify each finding was",
|
||||||
|
"addressed in the current diff and watch for new regressions:",
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
for (let i = 0; i < priorReviews.length; i++) {
|
||||||
|
const r = priorReviews[i];
|
||||||
|
if (!r) continue;
|
||||||
|
lines.push(`### Review ${i + 1} — ${r.verdict.toUpperCase()}`);
|
||||||
|
lines.push(`Summary: ${r.summary}`);
|
||||||
|
if (r.findings.length > 0) {
|
||||||
|
lines.push("Findings:");
|
||||||
|
for (const f of r.findings) {
|
||||||
|
const loc = f.file
|
||||||
|
? f.line
|
||||||
|
? ` (${f.file}:${f.line})`
|
||||||
|
: ` (${f.file})`
|
||||||
|
: "";
|
||||||
|
lines.push(`- [${f.severity}]${loc} ${f.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function reviewInstructions(): string[] {
|
function reviewInstructions(): string[] {
|
||||||
return [
|
return [
|
||||||
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
||||||
@@ -373,7 +541,7 @@ function reviewVerdictBlock(): string[] {
|
|||||||
"VERDICT: [pass | warn | fail]",
|
"VERDICT: [pass | warn | fail]",
|
||||||
"SUMMARY: [1-2 sentence overall assessment]",
|
"SUMMARY: [1-2 sentence overall assessment]",
|
||||||
"FINDINGS:",
|
"FINDINGS:",
|
||||||
"- [blocker] file:line description (use severity: blocker|warning|nit|info)",
|
"- [blocker] file:line description (use severity: blocker|warning|nit|info; `critical` is accepted as a blocker synonym)",
|
||||||
"- [warning] file:line description",
|
"- [warning] file:line description",
|
||||||
"```",
|
"```",
|
||||||
"",
|
"",
|
||||||
@@ -387,7 +555,8 @@ function reviewVerdictBlock(): string[] {
|
|||||||
"",
|
"",
|
||||||
"Each FINDINGS line uses the form `- [severity] [file:line] message`.",
|
"Each FINDINGS line uses the form `- [severity] [file:line] message`.",
|
||||||
"The `file:line` part is optional. Severity must be one of:",
|
"The `file:line` part is optional. Severity must be one of:",
|
||||||
"`blocker`, `warning`, `nit`, `info`.",
|
"`blocker`, `warning`, `nit`, `info`. The `critical` token is accepted",
|
||||||
|
"and treated as `blocker`.",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,13 +88,16 @@ function extractFindings(block: string): ReviewFinding[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const findings: ReviewFinding[] = [];
|
const findings: ReviewFinding[] = [];
|
||||||
const severityRe = /^\[(blocker|warning|warn|nit|info)\]\s*(.*)$/i;
|
// `critical` is accepted and normalized to ralpi's `blocker` severity,
|
||||||
|
// providing parity with @piex-dev/review's critical/warning/info grading.
|
||||||
|
const severityRe = /^\[(blocker|critical|warning|warn|nit|info)\]\s*(.*)$/i;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const sm = line.match(severityRe);
|
const sm = line.match(severityRe);
|
||||||
if (sm) {
|
if (sm) {
|
||||||
let sev = sm[1].toLowerCase();
|
let sev = sm[1].toLowerCase();
|
||||||
if (sev === "warn") sev = "warning";
|
if (sev === "warn") sev = "warning";
|
||||||
|
else if (sev === "critical") sev = "blocker";
|
||||||
const rest = sm[2].trim();
|
const rest = sm[2].trim();
|
||||||
const { file, line: lineNum, message } = parseFileRef(rest);
|
const { file, line: lineNum, message } = parseFileRef(rest);
|
||||||
findings.push({
|
findings.push({
|
||||||
|
|||||||
45
src/types.ts
45
src/types.ts
@@ -161,6 +161,10 @@ export interface TaskProgressInfo {
|
|||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
||||||
reviewRetries?: number;
|
reviewRetries?: number;
|
||||||
|
/** Path to the JSONL session file backing this task's agent session,
|
||||||
|
* persisted so a resume can reopen it and continue where the
|
||||||
|
* interrupted session left off. */
|
||||||
|
sessionFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressState {
|
export interface ProgressState {
|
||||||
@@ -205,6 +209,11 @@ export interface RalpiConfig {
|
|||||||
execution: {
|
execution: {
|
||||||
/** Task execution timeout in milliseconds */
|
/** Task execution timeout in milliseconds */
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
/** Inactivity timeout in milliseconds — if no agent session event
|
||||||
|
* arrives within this window, the task is considered hung (e.g. a
|
||||||
|
* bash subprocess that never returns) and the session is aborted.
|
||||||
|
* 0 = disabled. */
|
||||||
|
inactivityTimeoutMs: number;
|
||||||
/** Maximum parallel tasks (0 = unlimited) */
|
/** Maximum parallel tasks (0 = unlimited) */
|
||||||
maxParallel: number;
|
maxParallel: number;
|
||||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||||
@@ -246,18 +255,46 @@ export interface RalpiConfig {
|
|||||||
reviewBlockOnFail: boolean;
|
reviewBlockOnFail: boolean;
|
||||||
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
||||||
loopTimeoutMs: number;
|
loopTimeoutMs: number;
|
||||||
|
/** Max attempts on the SAME model before cycling to the next model on
|
||||||
|
* failure. Pi retries transient HTTP errors within a single prompt,
|
||||||
|
* but a sustained provider hiccup can still exhaust those in-call
|
||||||
|
* retries mid-session. Re-running the whole session a few times on
|
||||||
|
* the same model avoids flapping to a different model (and losing
|
||||||
|
* model-specific context) on the first hard failure. Applies to task
|
||||||
|
* execution, commit/review follow-up sessions, and review-fix
|
||||||
|
* re-execution alike. After this many attempts on one model, ralpi
|
||||||
|
* advances to the next model in the round-robin pool. */
|
||||||
|
maxSameModelAttempts: number;
|
||||||
/** Isolate each task in a separate git worktree so parallel tasks can't
|
/** Isolate each task in a separate git worktree so parallel tasks can't
|
||||||
* stomp each other's files, and review/commit see a clean single-task diff.
|
* stomp each other's files, and review/commit see a clean single-task diff.
|
||||||
* - "never": all tasks run in the shared working tree (default, backward compat)
|
* - "never": all tasks run in the shared working tree (default, backward compat)
|
||||||
* - "parallel": only when maxParallel > 1 and mode is parallel
|
* - "parallel": only when maxParallel > 1 and mode is parallel
|
||||||
* - "always": every task gets its own worktree */
|
* - "always": every task gets its own worktree */
|
||||||
worktrees: "always" | "parallel" | "never";
|
worktrees: "always" | "parallel" | "never";
|
||||||
|
/** Chat rendering style for tool calls during task execution.
|
||||||
|
* - "compact": single completion message per task with an expandable
|
||||||
|
* tool-call tree (collapsed shows last 3, expanded shows all).
|
||||||
|
* - "verbose": per-event stream — each tool start/end and assistant
|
||||||
|
* turn is its own chat line (piolium/pygienium-style). */
|
||||||
|
chatStyle: "compact" | "verbose";
|
||||||
};
|
};
|
||||||
prompts: {
|
prompts: {
|
||||||
/** Additional context injected into every task prompt */
|
/** Additional context injected into every task prompt */
|
||||||
projectContext: string;
|
projectContext: string;
|
||||||
/** Custom prompt suffix for reflection extraction */
|
/** Custom prompt suffix for reflection extraction */
|
||||||
reflectionPrompt: string;
|
reflectionPrompt: string;
|
||||||
|
/** Per-review custom focus/instructions (e.g. "check security only").
|
||||||
|
* Injected as a `### Custom Review Focus` section in committed and
|
||||||
|
* uncommitted review prompts when non-empty. */
|
||||||
|
reviewFocus: string;
|
||||||
|
};
|
||||||
|
review: {
|
||||||
|
/** Extra noise-filter exclusion regexes (strings compiled to RegExp),
|
||||||
|
* merged into EXCLUDED_PATTERNS for review diffs. */
|
||||||
|
extraIgnorePatterns: string[];
|
||||||
|
/** Pathspec allowlist — files matching these stay in scope even when a
|
||||||
|
* default noise rule would exclude them. */
|
||||||
|
ignorePaths: string[];
|
||||||
};
|
};
|
||||||
/** Parent session model to inherit in child agent sessions */
|
/** Parent session model to inherit in child agent sessions */
|
||||||
model?: unknown;
|
model?: unknown;
|
||||||
@@ -273,6 +310,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
inactivityTimeoutMs: 0, // 0 = disabled (no inactivity hang detection)
|
||||||
maxParallel: 3,
|
maxParallel: 3,
|
||||||
models: [],
|
models: [],
|
||||||
autoCommit: true,
|
autoCommit: true,
|
||||||
@@ -287,9 +325,16 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
|
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
|
||||||
loopTimeoutMs: 0, // 0 = no limit
|
loopTimeoutMs: 0, // 0 = no limit
|
||||||
worktrees: "parallel", // worktree isolation for parallel tasks by default
|
worktrees: "parallel", // worktree isolation for parallel tasks by default
|
||||||
|
maxSameModelAttempts: 5, // retry the same model up to 5 times before cycling to the next
|
||||||
|
chatStyle: "compact", // compact = completion message with tool-call tree; verbose = per-event stream
|
||||||
},
|
},
|
||||||
prompts: {
|
prompts: {
|
||||||
projectContext: "",
|
projectContext: "",
|
||||||
reflectionPrompt: "",
|
reflectionPrompt: "",
|
||||||
|
reviewFocus: "",
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
extraIgnorePatterns: [],
|
||||||
|
ignorePaths: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
199
src/utils.ts
199
src/utils.ts
@@ -100,6 +100,49 @@ export function deleteLoopActive(projectDir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Git Hygiene ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ralpiIgnoreMemo = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure `.ralpi/` is excluded from the project's `.gitignore` so ralpi's own
|
||||||
|
* run-state, worktrees, and reviews never show up as tracked/untracked files
|
||||||
|
* in the user's repo.
|
||||||
|
*
|
||||||
|
* Memoized per project dir; only acts inside a git work tree (`.git` may be a
|
||||||
|
* directory or, in linked worktrees, a file). Creates or appends `.ralpi/` to
|
||||||
|
* `.gitignore`, best-effort: any failure returns `false` (never throws).
|
||||||
|
*
|
||||||
|
* @returns true when the ignore entry was newly added, false otherwise.
|
||||||
|
*/
|
||||||
|
export function ensureRalpiIgnored(projectDir: string): boolean {
|
||||||
|
if (ralpiIgnoreMemo.has(projectDir)) return false;
|
||||||
|
ralpiIgnoreMemo.add(projectDir);
|
||||||
|
try {
|
||||||
|
// Only act inside a git work tree (works for worktrees too: .git is a file).
|
||||||
|
fs.statSync(path.join(projectDir, ".git"));
|
||||||
|
const ignorePath = path.join(projectDir, ".gitignore");
|
||||||
|
const marker = ".ralpi/";
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(ignorePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
fs.writeFileSync(ignorePath, `${marker}\n`, "utf8");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false;
|
||||||
|
const prefix = content.endsWith("\n") ? "" : "\n";
|
||||||
|
fs.appendFileSync(
|
||||||
|
ignorePath,
|
||||||
|
`${prefix}# ralpi run-state, worktrees, and reviews\n${marker}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false; // not a git work tree, or a best-effort write failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover the project directory by walking up to find `.ralpi/`.
|
* Discover the project directory by walking up to find `.ralpi/`.
|
||||||
*/
|
*/
|
||||||
@@ -568,6 +611,18 @@ export async function runAgentSession(
|
|||||||
* rate-limit normalization) are available. When omitted, the SDK creates
|
* rate-limit normalization) are available. When omitted, the SDK creates
|
||||||
* a fresh runtime from models.json only — extension providers are lost. */
|
* a fresh runtime from models.json only — extension providers are lost. */
|
||||||
modelRuntime?: ModelRuntime,
|
modelRuntime?: ModelRuntime,
|
||||||
|
/** Inactivity timeout in milliseconds — if no agent session event arrives
|
||||||
|
* within this window, the task is considered hung (e.g. a bash subprocess
|
||||||
|
* that never returns) and the session is aborted. 0 = disabled. */
|
||||||
|
inactivityTimeoutMs = 0,
|
||||||
|
/** Existing session JSONL file to resume. The session reopens the file and
|
||||||
|
* appends to it, so the agent sees the full prior conversation and can
|
||||||
|
* continue rather than redo prior tool calls. When the file is missing,
|
||||||
|
* a fresh session is created instead (with a warning). */
|
||||||
|
resumeSessionFile?: string,
|
||||||
|
/** Called with the session file path as soon as the session is created, so
|
||||||
|
* callers can persist it for resume before the session completes. */
|
||||||
|
onSessionFile?: (sessionFile: string) => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -575,6 +630,13 @@ export async function runAgentSession(
|
|||||||
toolUsage: ToolUsage;
|
toolUsage: ToolUsage;
|
||||||
stopReason?: string;
|
stopReason?: string;
|
||||||
events: AgentSessionEvent[];
|
events: AgentSessionEvent[];
|
||||||
|
/** Path to the JSONL session file backing this session (set once the
|
||||||
|
* session is created; enables resume). */
|
||||||
|
sessionFile?: string;
|
||||||
|
/** True when a resume was requested but the session could not be created
|
||||||
|
* from the file (corrupt/unreadable JSONL). Callers should clear the
|
||||||
|
* stored session file so retries start fresh. */
|
||||||
|
resumeFailed?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const toolUsage: ToolUsage = {
|
const toolUsage: ToolUsage = {
|
||||||
read: 0,
|
read: 0,
|
||||||
@@ -595,6 +657,16 @@ export async function runAgentSession(
|
|||||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
let sessionFile: string | undefined;
|
||||||
|
let sessionCreated = false;
|
||||||
|
// Inactivity watchdog: aborts the session when no events arrive within
|
||||||
|
// inactivityTimeoutMs. The SDK emits an event for every tool start/end/
|
||||||
|
// update and message start/end, so silence means the agent is stuck
|
||||||
|
// (typically a hung bash subprocess producing no output).
|
||||||
|
let inactivityInterval: NodeJS.Timeout | null = null;
|
||||||
|
let inactivityAborted = false;
|
||||||
|
let lastEventTime = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Loop sessions load the full normal pi context: extensions (so all
|
// Loop sessions load the full normal pi context: extensions (so all
|
||||||
// extension-provided tools register), skills, and project context
|
// extension-provided tools register), skills, and project context
|
||||||
@@ -610,9 +682,30 @@ export async function runAgentSession(
|
|||||||
});
|
});
|
||||||
await loader.reload();
|
await loader.reload();
|
||||||
|
|
||||||
|
// Persist sessions under the ralpi project's `.ralpi/sessions/` so they
|
||||||
|
// survive worktree removal and are findable from the main repo on resume.
|
||||||
|
// Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up
|
||||||
|
// from the agent's cwd always finds the main project's `.ralpi` first.
|
||||||
|
const ralpiDir = findRalpiDir(cwd);
|
||||||
|
const sessionDir = ralpiDir
|
||||||
|
? path.join(ralpiDir, ".ralpi", "sessions")
|
||||||
|
: path.join(cwd, ".ralpi", "sessions");
|
||||||
|
|
||||||
|
let sessionManager: SessionManager;
|
||||||
|
if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {
|
||||||
|
sessionManager = SessionManager.open(resumeSessionFile, sessionDir, cwd);
|
||||||
|
} else {
|
||||||
|
if (resumeSessionFile) {
|
||||||
|
console.warn(
|
||||||
|
`[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sessionManager = SessionManager.create(cwd, sessionDir);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await createAgentSession({
|
const result = await createAgentSession({
|
||||||
cwd,
|
cwd,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager,
|
||||||
resourceLoader: loader,
|
resourceLoader: loader,
|
||||||
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
||||||
modelRuntime,
|
modelRuntime,
|
||||||
@@ -620,8 +713,12 @@ export async function runAgentSession(
|
|||||||
model: model as any,
|
model: model as any,
|
||||||
thinkingLevel: thinkingLevel as any,
|
thinkingLevel: thinkingLevel as any,
|
||||||
});
|
});
|
||||||
|
sessionCreated = true;
|
||||||
sessionRef.session = result.session;
|
sessionRef.session = result.session;
|
||||||
|
|
||||||
|
sessionFile = result.session.sessionFile;
|
||||||
|
if (sessionFile) onSessionFile?.(sessionFile);
|
||||||
|
|
||||||
// Wire external abort signal
|
// Wire external abort signal
|
||||||
const abortHandler = () => result.session.agent.abort();
|
const abortHandler = () => result.session.agent.abort();
|
||||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||||
@@ -629,8 +726,26 @@ export async function runAgentSession(
|
|||||||
let finalText = "";
|
let finalText = "";
|
||||||
let errorMessage: string | undefined;
|
let errorMessage: string | undefined;
|
||||||
let stopReason: string | undefined;
|
let stopReason: string | undefined;
|
||||||
|
lastEventTime = Date.now();
|
||||||
|
|
||||||
|
// Inactivity watchdog: check the silence window on an interval and abort
|
||||||
|
// (plus kill any hung bash subprocess) when it is exceeded.
|
||||||
|
if (inactivityTimeoutMs > 0) {
|
||||||
|
const intervalMs = Math.min(inactivityTimeoutMs, 5000);
|
||||||
|
inactivityInterval = setInterval(() => {
|
||||||
|
if (!sessionRef.session) return;
|
||||||
|
if (Date.now() - lastEventTime <= inactivityTimeoutMs) return;
|
||||||
|
inactivityAborted = true;
|
||||||
|
sessionRef.session.agent.abort();
|
||||||
|
sessionRef.session.abortBash();
|
||||||
|
errorMessage = `Task aborted: inactivity timeout (no events for ${Math.round(inactivityTimeoutMs / 1000)}s)`;
|
||||||
|
if (inactivityInterval) clearInterval(inactivityInterval);
|
||||||
|
inactivityInterval = null;
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = result.session.subscribe((event) => {
|
const unsubscribe = result.session.subscribe((event) => {
|
||||||
|
lastEventTime = Date.now();
|
||||||
onEvent?.(event);
|
onEvent?.(event);
|
||||||
|
|
||||||
if (event.type === "message_end") {
|
if (event.type === "message_end") {
|
||||||
@@ -642,7 +757,10 @@ export async function runAgentSession(
|
|||||||
};
|
};
|
||||||
if (message.role !== "assistant") return;
|
if (message.role !== "assistant") return;
|
||||||
if (message.stopReason) stopReason = message.stopReason;
|
if (message.stopReason) stopReason = message.stopReason;
|
||||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
// Keep the inactivity-timeout message: the abort's own errorMessage
|
||||||
|
// would otherwise clobber the (more useful) hang explanation.
|
||||||
|
if (message.errorMessage && !inactivityAborted)
|
||||||
|
errorMessage = message.errorMessage;
|
||||||
const text = extractAssistantText(message.content);
|
const text = extractAssistantText(message.content);
|
||||||
if (text) finalText = text;
|
if (text) finalText = text;
|
||||||
}
|
}
|
||||||
@@ -675,6 +793,7 @@ export async function runAgentSession(
|
|||||||
toolUsage,
|
toolUsage,
|
||||||
stopReason,
|
stopReason,
|
||||||
events: [], // streamed to file
|
events: [], // streamed to file
|
||||||
|
sessionFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +803,7 @@ export async function runAgentSession(
|
|||||||
toolUsage,
|
toolUsage,
|
||||||
stopReason,
|
stopReason,
|
||||||
events: [],
|
events: [],
|
||||||
|
sessionFile,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
@@ -693,9 +813,14 @@ export async function runAgentSession(
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
toolUsage,
|
toolUsage,
|
||||||
events: [],
|
events: [],
|
||||||
|
sessionFile,
|
||||||
|
// A requested resume that failed to open (corrupt/unreadable file)
|
||||||
|
// should not be retried — callers clear the stored file and go fresh.
|
||||||
|
resumeFailed: resumeSessionFile !== undefined && !sessionCreated,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
sessionRef.session?.dispose();
|
sessionRef.session?.dispose();
|
||||||
|
if (inactivityInterval) clearInterval(inactivityInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,17 +1035,49 @@ export function captureGitHead(projectDir: string): string | undefined {
|
|||||||
* made since the base reference. Used by the review-gated loop so the reviewer
|
* made since the base reference. Used by the review-gated loop so the reviewer
|
||||||
* sees the full task diff (all commits, not just the latest) across execution
|
* sees the full task diff (all commits, not just the latest) across execution
|
||||||
* attempts and re-execution fixes. `baseRef` must be a validated hex SHA from
|
* attempts and re-execution fixes. `baseRef` must be a validated hex SHA from
|
||||||
* captureGitHead(). Returns the short HEAD hash, HEAD subject, and range diff,
|
* captureGitHead().
|
||||||
* or null when git is unavailable / baseRef is invalid / no changes exist.
|
*
|
||||||
|
* Returns a tri-state so the review loop can tell a FAILED range computation
|
||||||
|
* (invalid/stale base ref, git error) apart from a GENUINELY EMPTY range — a
|
||||||
|
* broken base must never be silently treated as a clean, verified task.
|
||||||
*/
|
*/
|
||||||
|
export type CommitRangeDiffResult =
|
||||||
|
| { kind: "ok"; hash: string; subject: string; diff: string }
|
||||||
|
| { kind: "no-changes" }
|
||||||
|
| { kind: "error"; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the `baseRef..HEAD` range can be computed — i.e. the base ref is a
|
||||||
|
* resolvable commit in this repo (mirrors @piex-dev/review's canCompareToBase).
|
||||||
|
* Only validated hex SHAs are passed to the shell.
|
||||||
|
*/
|
||||||
|
export function canComputeRange(projectDir: string, baseRef: string): boolean {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return false;
|
||||||
|
try {
|
||||||
|
// git cat-file -e truly verifies the object EXISTS (rev-parse --verify
|
||||||
|
// accepts any 40-hex SHA even if it was never created), so a stale/broken
|
||||||
|
// base ref is caught here rather than silently treated as no-changes.
|
||||||
|
execSync(`git cat-file -e ${baseRef}`, {
|
||||||
|
cwd: projectDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getCommitRangeDiff(
|
export function getCommitRangeDiff(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
baseRef: string,
|
baseRef: string,
|
||||||
): { hash: string; subject: string; diff: string } | null {
|
): CommitRangeDiffResult {
|
||||||
const { execSync } = require("node:child_process");
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
// Only pass validated hex SHAs to the shell.
|
// Only pass validated hex SHAs to the shell.
|
||||||
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return null;
|
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) {
|
||||||
|
return { kind: "error", error: "invalid or stale base ref" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync("git rev-parse --git-dir", {
|
execSync("git rev-parse --git-dir", {
|
||||||
@@ -928,7 +1085,20 @@ export function getCommitRangeDiff(
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { kind: "error", error: "not a git repository" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the base ref resolves before diffing — a stale/unfetched ref is a
|
||||||
|
// computation failure, not a clean "no changes" signal. git cat-file -e
|
||||||
|
// checks the object genuinely exists (rev-parse --verify would accept any
|
||||||
|
// 40-hex SHA even if it was never created).
|
||||||
|
try {
|
||||||
|
execSync(`git cat-file -e ${baseRef}`, {
|
||||||
|
cwd: projectDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", error: `base ref ${baseRef} cannot be resolved` };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -946,17 +1116,20 @@ export function getCommitRangeDiff(
|
|||||||
// the snapshot. Includes stat overview + full patch.
|
// the snapshot. Includes stat overview + full patch.
|
||||||
//
|
//
|
||||||
// maxBuffer is set high (10 MB) so larger tasks don't cause execSync to
|
// maxBuffer is set high (10 MB) so larger tasks don't cause execSync to
|
||||||
// throw. The review prompt builder truncates to MAX_DIFF_BYTES (50 KB)
|
// throw. The review prompt builder filters noise and inlines only under
|
||||||
// before sending to the model, so the full diff in memory is fine.
|
// MAX_DIFF_BYTES, so the full diff in memory is fine.
|
||||||
const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, {
|
const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, {
|
||||||
cwd: projectDir,
|
cwd: projectDir,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
if (!diff) return null; // no changes since baseRef
|
if (!diff) return { kind: "no-changes" }; // genuinely no changes since baseRef
|
||||||
return { hash, subject, diff };
|
return { kind: "ok", hash, subject, diff };
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
return {
|
||||||
|
kind: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
tests/commit-range-diff.test.ts
Normal file
82
tests/commit-range-diff.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the tri-state commit-range diff (src/utils.ts getCommitRangeDiff):
|
||||||
|
* a FAILED range computation (invalid/stale base ref, git error) must be a
|
||||||
|
* distinct `error` signal, never collapsed into a clean `no-changes` — a
|
||||||
|
* broken base ref must never be silently treated as a verified task.
|
||||||
|
*
|
||||||
|
* Uses a real throwaway git repo so the shell-out behavior is exercised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { getCommitRangeDiff } from "../src/utils";
|
||||||
|
|
||||||
|
let repoDir: string;
|
||||||
|
|
||||||
|
function sh(cmd: string, cwd: string) {
|
||||||
|
execSync(cmd, { cwd, stdio: "pipe" });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-crd-"));
|
||||||
|
sh("git init -q", repoDir);
|
||||||
|
sh("git config user.email test@example.com", repoDir);
|
||||||
|
sh("git config user.name test", repoDir);
|
||||||
|
fs.writeFileSync(path.join(repoDir, "a.ts"), "one\n", "utf-8");
|
||||||
|
sh("git add -A", repoDir);
|
||||||
|
sh("git commit -q -m init", repoDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCommitRangeDiff tri-state", () => {
|
||||||
|
test("ok: a resolvable base with committed changes yields the diff", () => {
|
||||||
|
fs.writeFileSync(path.join(repoDir, "a.ts"), "one\ntwo\n", "utf-8");
|
||||||
|
sh("git add -A", repoDir);
|
||||||
|
sh("git commit -q -m change", repoDir);
|
||||||
|
|
||||||
|
const base = execSync("git rev-parse HEAD~1", {
|
||||||
|
cwd: repoDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const result = getCommitRangeDiff(repoDir, base);
|
||||||
|
expect(result.kind).toBe("ok");
|
||||||
|
if (result.kind === "ok") {
|
||||||
|
expect(result.diff).toContain("a.ts");
|
||||||
|
expect(result.hash.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error: a fake/unresolvable base ref yields the failure signal, not no-changes", () => {
|
||||||
|
// 40 hex chars that never existed in this repo.
|
||||||
|
const fake = "ffffffffffffffffffffffffffffffffffffffff";
|
||||||
|
const result = getCommitRangeDiff(repoDir, fake);
|
||||||
|
expect(result.kind).toBe("error");
|
||||||
|
if (result.kind === "error") {
|
||||||
|
expect(result.error).toContain("cannot be resolved");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error: a non-hex base ref is rejected before reaching the shell", () => {
|
||||||
|
const result = getCommitRangeDiff(repoDir, "HEAD~1; rm -rf /");
|
||||||
|
expect(result.kind).toBe("error");
|
||||||
|
if (result.kind === "error") {
|
||||||
|
expect(result.error).toContain("invalid or stale base ref");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-changes: an empty range (base == HEAD) yields the no-changes signal", () => {
|
||||||
|
const head = execSync("git rev-parse HEAD", {
|
||||||
|
cwd: repoDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
const result = getCommitRangeDiff(repoDir, head);
|
||||||
|
expect(result.kind).toBe("no-changes");
|
||||||
|
});
|
||||||
|
});
|
||||||
237
tests/diff.test.ts
Normal file
237
tests/diff.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the noise-filtered diff engine (src/diff.ts).
|
||||||
|
* Covers: per-file +/− parsing, excluded-file split, totals excluding noise,
|
||||||
|
* malformed-chunk guard, isExcluded reasons, and configurable overrides.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
parseDiff,
|
||||||
|
filterNoise,
|
||||||
|
isExcluded,
|
||||||
|
compileIgnorePatterns,
|
||||||
|
EXCLUDED_PATTERNS,
|
||||||
|
} from "../src/diff";
|
||||||
|
|
||||||
|
/** A synthetic unified diff mixing code, a lockfile, a minified file, and a binary. */
|
||||||
|
const SYNTH_DIFF = [
|
||||||
|
"diff --git a/src/index.ts b/src/index.ts",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"--- a/src/index.ts",
|
||||||
|
"+++ b/src/index.ts",
|
||||||
|
"@@ -1,2 +1,4 @@",
|
||||||
|
' import { foo } from "./foo";',
|
||||||
|
"+export const baz = 1;",
|
||||||
|
"+export const qux = 2;",
|
||||||
|
"-foo();",
|
||||||
|
"+bar();",
|
||||||
|
"",
|
||||||
|
"diff --git a/package-lock.json b/package-lock.json",
|
||||||
|
"index 000..111 100644",
|
||||||
|
"--- a/package-lock.json",
|
||||||
|
"+++ b/package-lock.json",
|
||||||
|
"@@ -0,0 +1,3 @@",
|
||||||
|
"+{",
|
||||||
|
'+ "name": "x"',
|
||||||
|
"+}",
|
||||||
|
"",
|
||||||
|
"diff --git a/dist/foo.min.js b/dist/foo.min.js",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"--- a/dist/foo.min.js",
|
||||||
|
"+++ b/dist/foo.min.js",
|
||||||
|
"@@ -1 +1 @@",
|
||||||
|
"-var a=1;",
|
||||||
|
"+var a=2;",
|
||||||
|
"",
|
||||||
|
"diff --git a/assets/logo.png b/assets/logo.png",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"Binary files differ",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("parseDiff", () => {
|
||||||
|
test("splits included vs excluded files and totals only included", () => {
|
||||||
|
const summary = parseDiff(SYNTH_DIFF);
|
||||||
|
|
||||||
|
// Included: only src/index.ts (code). Lockfile, minified, binary excluded.
|
||||||
|
expect(summary.files).toHaveLength(1);
|
||||||
|
expect(summary.files[0]).toEqual({
|
||||||
|
path: "src/index.ts",
|
||||||
|
linesAdded: 3,
|
||||||
|
linesRemoved: 1,
|
||||||
|
ext: "ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.excluded).toHaveLength(3);
|
||||||
|
const byPath = new Map(
|
||||||
|
summary.excluded.map((f) => [f.path, f]),
|
||||||
|
);
|
||||||
|
expect(byPath.get("package-lock.json")).toMatchObject({
|
||||||
|
linesAdded: 3,
|
||||||
|
linesRemoved: 0,
|
||||||
|
reason: "lockfile",
|
||||||
|
});
|
||||||
|
expect(byPath.get("dist/foo.min.js")).toMatchObject({
|
||||||
|
linesAdded: 1,
|
||||||
|
linesRemoved: 1,
|
||||||
|
reason: "minified asset",
|
||||||
|
});
|
||||||
|
expect(byPath.get("assets/logo.png")).toMatchObject({
|
||||||
|
linesAdded: 0,
|
||||||
|
linesRemoved: 0,
|
||||||
|
reason: "binary/media asset",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Totals exclude the noise files.
|
||||||
|
expect(summary.totalAdded).toBe(3);
|
||||||
|
expect(summary.totalRemoved).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty summary for an empty diff", () => {
|
||||||
|
const summary = parseDiff("");
|
||||||
|
expect(summary.files).toHaveLength(0);
|
||||||
|
expect(summary.excluded).toHaveLength(0);
|
||||||
|
expect(summary.totalAdded).toBe(0);
|
||||||
|
expect(summary.totalRemoved).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips malformed chunks without a/… b/ header without crashing", () => {
|
||||||
|
const malformed =
|
||||||
|
"diff --git weird-line\nindex 111..222\n--- a/x\n+++ b/x\n+x\n" +
|
||||||
|
"\n" +
|
||||||
|
"diff --git a/src/ok.ts b/src/ok.ts\n--- a/src/ok.ts\n+++ b/src/ok.ts\n+ok\n";
|
||||||
|
const summary = parseDiff(malformed);
|
||||||
|
// Only the well-formed chunk is counted.
|
||||||
|
expect(summary.files).toHaveLength(1);
|
||||||
|
expect(summary.files[0].path).toBe("src/ok.ts");
|
||||||
|
expect(summary.totalAdded).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not count +++/--- header lines as additions/removals", () => {
|
||||||
|
const diff = [
|
||||||
|
"diff --git a/src/a.ts b/src/a.ts",
|
||||||
|
"--- a/src/a.ts",
|
||||||
|
"+++ b/src/a.ts",
|
||||||
|
"@@ -0,0 +1,2 @@",
|
||||||
|
"+one",
|
||||||
|
"+two",
|
||||||
|
].join("\n");
|
||||||
|
const summary = parseDiff(diff);
|
||||||
|
expect(summary.files[0].linesAdded).toBe(2);
|
||||||
|
expect(summary.files[0].linesRemoved).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isExcluded", () => {
|
||||||
|
test("returns the right reason per pattern", () => {
|
||||||
|
expect(isExcluded("package-lock.json")).toBe("lockfile");
|
||||||
|
expect(isExcluded("yarn.lock")).toBe("lockfile");
|
||||||
|
expect(isExcluded("src/app.min.js")).toBe("minified asset");
|
||||||
|
expect(isExcluded("src/styles.min.css")).toBe("minified asset");
|
||||||
|
expect(isExcluded("build/out.js")).toBe("build output");
|
||||||
|
expect(isExcluded("node_modules/foo/index.js")).toBe("dependency");
|
||||||
|
expect(isExcluded("vendor/lib.bundle.js")).toBe("vendored dependency");
|
||||||
|
expect(isExcluded("assets/icon.svg")).toBe("binary/media asset");
|
||||||
|
expect(isExcluded("src/api.generated.ts")).toBe("generated file");
|
||||||
|
expect(isExcluded("test/__snapshots__/x.snap")).toBe("snapshot");
|
||||||
|
expect(isExcluded("dist/x.js.map")).toBe("source map");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for review-relevant files", () => {
|
||||||
|
expect(isExcluded("src/foo.ts")).toBeUndefined();
|
||||||
|
expect(isExcluded("src/index.ts")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges caller-supplied extra patterns", () => {
|
||||||
|
expect(isExcluded("src/data.foo", [/\.foo$/])).toBe("extra ignore pattern");
|
||||||
|
expect(isExcluded("src/data.foo")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EXCLUDED_PATTERNS covers lockfiles, min, generated, snap, map, build, vendor, binaries", () => {
|
||||||
|
for (const pat of [
|
||||||
|
"package-lock.json",
|
||||||
|
"src/app.min.js",
|
||||||
|
"src/thing.generated.ts",
|
||||||
|
"x.snap",
|
||||||
|
"x.js.map",
|
||||||
|
"dist/bundle.js",
|
||||||
|
"node_modules/a/b.js",
|
||||||
|
"vendor/x",
|
||||||
|
"a.png",
|
||||||
|
"f.woff2",
|
||||||
|
]) {
|
||||||
|
const hit = EXCLUDED_PATTERNS.some((r) => r.pattern.test(pat));
|
||||||
|
expect(hit, `${pat} should be covered by a default rule`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterNoise", () => {
|
||||||
|
test("re-emits only included-file chunks", () => {
|
||||||
|
const filtered = filterNoise(SYNTH_DIFF);
|
||||||
|
expect(filtered).toContain("diff --git a/src/index.ts");
|
||||||
|
expect(filtered).not.toContain("package-lock.json");
|
||||||
|
expect(filtered).not.toContain("foo.min.js");
|
||||||
|
expect(filtered).not.toContain("logo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty when every file is noise", () => {
|
||||||
|
const onlyNoise = [
|
||||||
|
"diff --git a/package-lock.json b/package-lock.json",
|
||||||
|
"--- a/package-lock.json",
|
||||||
|
"+++ b/package-lock.json",
|
||||||
|
"+x",
|
||||||
|
].join("\n");
|
||||||
|
expect(filterNoise(onlyNoise)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configurable noise rules", () => {
|
||||||
|
test("extraPatterns excludes a matching file from the review diff", () => {
|
||||||
|
const diff = [
|
||||||
|
"diff --git a/src/foo.ts b/src/foo.ts",
|
||||||
|
"--- a/src/foo.ts",
|
||||||
|
"+++ b/src/foo.ts",
|
||||||
|
"+keep",
|
||||||
|
"diff --git a/src/data.foo b/src/data.foo",
|
||||||
|
"--- a/src/data.foo",
|
||||||
|
"+++ b/src/data.foo",
|
||||||
|
"+drop",
|
||||||
|
].join("\n");
|
||||||
|
const opts = { extraPatterns: compileIgnorePatterns(["\\.foo$"]) };
|
||||||
|
const summary = parseDiff(diff, opts);
|
||||||
|
expect(summary.files.map((f) => f.path)).toEqual(["src/foo.ts"]);
|
||||||
|
expect(summary.excluded.map((f) => [f.path, f.reason])).toEqual([
|
||||||
|
["src/data.foo", "extra ignore pattern"],
|
||||||
|
]);
|
||||||
|
expect(filterNoise(diff, opts)).not.toContain("data.foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignorePaths keeps an excluded-by-default file in scope", () => {
|
||||||
|
const diff = [
|
||||||
|
"diff --git a/package-lock.json b/package-lock.json",
|
||||||
|
"--- a/package-lock.json",
|
||||||
|
"+++ b/package-lock.json",
|
||||||
|
"+a",
|
||||||
|
"+b",
|
||||||
|
"+c",
|
||||||
|
].join("\n");
|
||||||
|
const opts = { ignorePaths: ["package-lock.json"] };
|
||||||
|
const summary = parseDiff(diff, opts);
|
||||||
|
expect(summary.files).toHaveLength(1);
|
||||||
|
expect(summary.files[0].path).toBe("package-lock.json");
|
||||||
|
expect(summary.excluded).toHaveLength(0);
|
||||||
|
expect(summary.totalAdded).toBe(3);
|
||||||
|
expect(filterNoise(diff, opts)).toContain("package-lock.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default behavior unchanged when overrides are unset", () => {
|
||||||
|
const summary = parseDiff(SYNTH_DIFF);
|
||||||
|
expect(summary.files[0].path).toBe("src/index.ts");
|
||||||
|
expect(summary.totalAdded).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileIgnorePatterns skips invalid regexes", () => {
|
||||||
|
const compiled = compileIgnorePatterns(["\\.foo$", "(", "ok$"]);
|
||||||
|
expect(compiled.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/gitignore-hygiene.test.ts
Normal file
96
tests/gitignore-hygiene.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { tempDir } from "./helpers";
|
||||||
|
import { ensureRalpiIgnored } from "../src/utils";
|
||||||
|
|
||||||
|
// ─── Gitignore hygiene: ensureRalpiIgnored ──────────────────────────────────
|
||||||
|
|
||||||
|
describe("ensureRalpiIgnored", () => {
|
||||||
|
it("creates .gitignore with .ralpi/ when absent in a git work tree", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(dir, ".git"));
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(true);
|
||||||
|
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
|
||||||
|
expect(content).toContain(".ralpi/");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends .ralpi/ to an existing .gitignore without the marker", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(dir, ".git"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, ".gitignore"),
|
||||||
|
"node_modules/\n*.log\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(true);
|
||||||
|
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
|
||||||
|
expect(content).toContain("node_modules/");
|
||||||
|
expect(content).toContain(".ralpi/");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a .gitignore with the marker untouched", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(dir, ".git"));
|
||||||
|
fs.writeFileSync(path.join(dir, ".gitignore"), ".ralpi/\n", "utf8");
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(false);
|
||||||
|
expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe(
|
||||||
|
".ralpi/\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op outside a git work tree", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is memoized per project dir", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(dir, ".git"));
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(true);
|
||||||
|
// Second call: same dir already handled → no further work.
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(false);
|
||||||
|
fs.writeFileSync(path.join(dir, ".gitignore"), "old\n", "utf8");
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(false);
|
||||||
|
expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe(
|
||||||
|
"old\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when .git is a file (linked git worktree)", () => {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, ".git"),
|
||||||
|
"gitdir: /some/shared/repo\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(ensureRalpiIgnored(dir)).toBe(true);
|
||||||
|
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
|
||||||
|
expect(content).toContain(".ralpi/");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
303
tests/review-prompt.test.ts
Normal file
303
tests/review-prompt.test.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the review prompt builders (src/prompts.ts).
|
||||||
|
* Covers: per-file summary table, excluded-files section, oversized-diff
|
||||||
|
* read-instruction (never byte-truncates), custom review focus, and the
|
||||||
|
* configurable noise-filter overrides surfacing in the prompt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildReviewPrompt,
|
||||||
|
buildReviewPromptUncommitted,
|
||||||
|
} from "../src/prompts";
|
||||||
|
import { compileIgnorePatterns } from "../src/diff";
|
||||||
|
import type { Task, Project } from "../src/types";
|
||||||
|
|
||||||
|
const task: Task = {
|
||||||
|
id: "01",
|
||||||
|
title: "Implement auth",
|
||||||
|
description: "Add a login flow",
|
||||||
|
status: "completed",
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
objective: "Build the app",
|
||||||
|
sourcePath: "README.md",
|
||||||
|
sourceDir: "/tmp",
|
||||||
|
tasks: [task],
|
||||||
|
dependencies: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A diff mixing one code file plus lockfile/minified/binary noise. */
|
||||||
|
const MIXED_DIFF = [
|
||||||
|
"diff --git a/src/auth.ts b/src/auth.ts",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"--- a/src/auth.ts",
|
||||||
|
"+++ b/src/auth.ts",
|
||||||
|
"@@ -1,3 +1,5 @@",
|
||||||
|
' import { hash } from "./hash";',
|
||||||
|
"+export function login() {",
|
||||||
|
"+ return hash(secret);",
|
||||||
|
"- return legacy();",
|
||||||
|
"+}",
|
||||||
|
"",
|
||||||
|
"diff --git a/package-lock.json b/package-lock.json",
|
||||||
|
"index 000..111 100644",
|
||||||
|
"--- a/package-lock.json",
|
||||||
|
"+++ b/package-lock.json",
|
||||||
|
"@@ -0,0 +1,3 @@",
|
||||||
|
"+{",
|
||||||
|
'+ "name": "x"',
|
||||||
|
"+}",
|
||||||
|
"",
|
||||||
|
"diff --git a/assets/logo.png b/assets/logo.png",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"Binary files differ",
|
||||||
|
"",
|
||||||
|
"diff --git a/dist/app.min.js b/dist/app.min.js",
|
||||||
|
"index 111..222 100644",
|
||||||
|
"--- a/dist/app.min.js",
|
||||||
|
"+++ b/dist/app.min.js",
|
||||||
|
"@@ -1 +1 @@",
|
||||||
|
"-var a=1;",
|
||||||
|
"+var a=2;",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function manyFileDiff(n: number): string {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
chunks.push(
|
||||||
|
`diff --git a/src/f${String(i).padStart(2, "0")}.ts b/src/f${String(i).padStart(2, "0")}.ts`,
|
||||||
|
"--- a/src/f.ts",
|
||||||
|
"+++ b/src/f.ts",
|
||||||
|
`+line ${i}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return chunks.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildReviewPrompt", () => {
|
||||||
|
test("emits a per-file +/− summary table with totals, excluding noise", () => {
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt).toContain("### Changed Files");
|
||||||
|
expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |");
|
||||||
|
expect(prompt).toContain("| **Total** | **+3/-1** | |");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces an excluded-files section with path, counts, and reason", () => {
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt).toContain("### Excluded Files (3)");
|
||||||
|
expect(prompt).toContain("- `package-lock.json` (+3/-0) — lockfile");
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"- `assets/logo.png` (+0/-0) — binary/media asset",
|
||||||
|
);
|
||||||
|
expect(prompt).toContain("- `dist/app.min.js` (+1/-1) — minified asset");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never inlines excluded (noise) chunks into the diff block", () => {
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The noise chunks themselves are never inlined — only the excluded-files
|
||||||
|
// section names them (as `- path (+x/-y) — reason`, no `diff --git` header).
|
||||||
|
expect(prompt).not.toContain("diff --git a/package-lock.json");
|
||||||
|
expect(prompt).not.toContain("diff --git a/assets/logo.png");
|
||||||
|
expect(prompt).not.toContain("diff --git a/dist/app.min.js");
|
||||||
|
// The cleaned diff block is present with the code file.
|
||||||
|
expect(prompt).toContain("```diff");
|
||||||
|
expect(prompt).toContain("diff --git a/src/auth.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits the excluded section entirely when nothing is excluded", () => {
|
||||||
|
const clean = [
|
||||||
|
"diff --git a/src/auth.ts b/src/auth.ts",
|
||||||
|
"--- a/src/auth.ts",
|
||||||
|
"+++ b/src/auth.ts",
|
||||||
|
"+export const x = 1;",
|
||||||
|
].join("\n");
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
clean,
|
||||||
|
);
|
||||||
|
expect(prompt).not.toContain("### Excluded Files");
|
||||||
|
expect(prompt).toContain("| `src/auth.ts` | +1/-0 | ts |");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switches to a file-list + read instruction for >20 files, no truncation", () => {
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: many",
|
||||||
|
manyFileDiff(21),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt).toContain("Diff too large");
|
||||||
|
expect(prompt).toContain("Use `read` to inspect the changed files");
|
||||||
|
// No byte-truncated inline diff for oversized inputs.
|
||||||
|
expect(prompt).not.toContain("```diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switches to a file-list + read instruction for a >50KB diff, no truncation", () => {
|
||||||
|
// One file but a huge cleaned diff — crosses MAX_DIFF_BYTES (50_000).
|
||||||
|
const huge = [
|
||||||
|
"diff --git a/src/auth.ts b/src/auth.ts",
|
||||||
|
"--- a/src/auth.ts",
|
||||||
|
"+++ b/src/auth.ts",
|
||||||
|
...Array.from(
|
||||||
|
{ length: 26000 },
|
||||||
|
() => "+padding line to blow past the size threshold",
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
huge,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt).toContain("Diff too large");
|
||||||
|
expect(prompt).toContain("Use `read` to inspect the changed files");
|
||||||
|
expect(prompt).toContain("src/auth.ts");
|
||||||
|
// No byte-truncated inline diff for the oversized input.
|
||||||
|
expect(prompt).not.toContain("```diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a small diff over the file-count branch still inlines under size threshold", () => {
|
||||||
|
// 5 files, small diff — under MAX_REVIEW_FILES and MAX_DIFF_BYTES → inlined.
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: small",
|
||||||
|
manyFileDiff(5),
|
||||||
|
);
|
||||||
|
expect(prompt).toContain("```diff");
|
||||||
|
expect(prompt).not.toContain("Diff too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inlines a small diff normally (no read-instruction)", () => {
|
||||||
|
const prompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
expect(prompt).not.toContain("Diff too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a Custom Review Focus section only when focus is set", () => {
|
||||||
|
const withFocus = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
{ focus: "check security only" },
|
||||||
|
);
|
||||||
|
expect(withFocus).toContain("## Custom Review Focus");
|
||||||
|
expect(withFocus).toContain("check security only");
|
||||||
|
|
||||||
|
const withoutFocus = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"abc1234",
|
||||||
|
"feat: auth",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
expect(withoutFocus).not.toContain("## Custom Review Focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces extra ignore patterns and ignorePaths overrides in the prompt", () => {
|
||||||
|
const diff = [
|
||||||
|
"diff --git a/src/keep.ts b/src/keep.ts",
|
||||||
|
"--- a/src/keep.ts",
|
||||||
|
"+++ b/src/keep.ts",
|
||||||
|
"+keep",
|
||||||
|
"diff --git a/package-lock.json b/package-lock.json",
|
||||||
|
"--- a/package-lock.json",
|
||||||
|
"+++ b/package-lock.json",
|
||||||
|
"+a",
|
||||||
|
"+b",
|
||||||
|
"+c",
|
||||||
|
"+d",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// ignorePaths keeps the lockfile in scope → it shows in the table,
|
||||||
|
// and no excluded section is emitted.
|
||||||
|
const kept = buildReviewPrompt(task, project, "abc1234", "x", diff, {
|
||||||
|
diffOptions: { ignorePaths: ["package-lock.json"] },
|
||||||
|
});
|
||||||
|
expect(kept).toContain("| `package-lock.json` | +4/-0 | json |");
|
||||||
|
expect(kept).not.toContain("### Excluded Files");
|
||||||
|
|
||||||
|
// Without ignorePaths, the lockfile is excluded.
|
||||||
|
const excluded = buildReviewPrompt(task, project, "abc1234", "x", diff);
|
||||||
|
expect(excluded).not.toContain("| `package-lock.json` |");
|
||||||
|
expect(excluded).toContain("### Excluded Files (1)");
|
||||||
|
|
||||||
|
// extraPatterns drops a matching file from scope.
|
||||||
|
const dropped = buildReviewPrompt(task, project, "abc1234", "x", diff, {
|
||||||
|
diffOptions: {
|
||||||
|
extraPatterns: compileIgnorePatterns(["\\.ts$"]),
|
||||||
|
ignorePaths: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(dropped).not.toContain("| `src/keep.ts` |");
|
||||||
|
expect(dropped).toContain("### Excluded Files (2)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildReviewPromptUncommitted", () => {
|
||||||
|
test("emits summary table, excluded section, and cleaned diff", () => {
|
||||||
|
const prompt = buildReviewPromptUncommitted(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"M src/auth.ts",
|
||||||
|
MIXED_DIFF,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt).toContain("### Changed Files");
|
||||||
|
expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |");
|
||||||
|
expect(prompt).toContain("### Excluded Files (3)");
|
||||||
|
expect(prompt).not.toContain("diff --git a/package-lock.json");
|
||||||
|
expect(prompt).toContain("### Current Tracked Diff (git diff)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports custom focus", () => {
|
||||||
|
const prompt = buildReviewPromptUncommitted(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
"M src/auth.ts",
|
||||||
|
MIXED_DIFF,
|
||||||
|
{ focus: "review performance" },
|
||||||
|
);
|
||||||
|
expect(prompt).toContain("## Custom Review Focus");
|
||||||
|
expect(prompt).toContain("review performance");
|
||||||
|
});
|
||||||
|
});
|
||||||
53
tests/review-severity.test.ts
Normal file
53
tests/review-severity.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the severity taxonomy alignment in review verdict parsing
|
||||||
|
* (src/review.ts): the `critical` token is accepted and normalized to
|
||||||
|
* ralpi's `blocker` severity, mirroring @piex-dev/review's grading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { extractReview } from "../src/review";
|
||||||
|
|
||||||
|
/** Build a full review-agent output ending in a REVIEW VERDICT block. */
|
||||||
|
function reviewOutput(findings: string[]): string {
|
||||||
|
return [
|
||||||
|
"Prose: looks mostly fine, a few issues to fix.",
|
||||||
|
"## REVIEW VERDICT",
|
||||||
|
"VERDICT: fail",
|
||||||
|
"SUMMARY: Needs fixes.",
|
||||||
|
"FINDINGS:",
|
||||||
|
...findings,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extractReview severity normalization", () => {
|
||||||
|
test("maps critical → blocker, keeps warning/nit/info", () => {
|
||||||
|
const out = reviewOutput([
|
||||||
|
"- [critical] src/auth.ts:12 hardcoded secret",
|
||||||
|
"- [warning] src/auth.ts:30 unused import",
|
||||||
|
"- [nit] src/auth.ts:5 style",
|
||||||
|
"- [info] src/auth.ts:1 note",
|
||||||
|
]);
|
||||||
|
const review = extractReview(out, "01", "abc1234");
|
||||||
|
expect(review).not.toBeNull();
|
||||||
|
const severities = review!.findings.map((f) => f.severity);
|
||||||
|
expect(severities).toEqual(["blocker", "warning", "nit", "info"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes the warn synonym to warning", () => {
|
||||||
|
const out = reviewOutput(["- [warn] src/a.ts:2 thing"]);
|
||||||
|
const review = extractReview(out, "01", "abc1234");
|
||||||
|
expect(review!.findings[0].severity).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uppercase CRITICAL token also maps to blocker", () => {
|
||||||
|
const out = reviewOutput(["- [CRITICAL] src/a.ts:2 thing"]);
|
||||||
|
const review = extractReview(out, "01", "abc1234");
|
||||||
|
expect(review!.findings[0].severity).toBe("blocker");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findings without a severity are still parsed", () => {
|
||||||
|
const out = reviewOutput(["- src/a.ts:2 plain line"]);
|
||||||
|
const review = extractReview(out, "01", "abc1234");
|
||||||
|
expect(review!.findings[0].severity).toBe("info");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user