Compare commits
5 Commits
v3.8.1
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48907ae2e | ||
|
|
ac81e1d7cd | ||
|
|
9390f98f01 | ||
|
|
e6868e9112 | ||
|
|
5d1d87cc10 |
372
src/agents/atlas/gemini.ts
Normal file
372
src/agents/atlas/gemini.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Gemini-optimized Atlas System Prompt
|
||||
*
|
||||
* Key differences from Claude/GPT variants:
|
||||
* - EXTREME delegation enforcement (Gemini strongly prefers doing work itself)
|
||||
* - Aggressive verification language (Gemini trusts subagent claims too readily)
|
||||
* - Repeated tool-call mandates (Gemini skips tool calls in favor of reasoning)
|
||||
* - Consequence-driven framing (Gemini ignores soft warnings)
|
||||
*/
|
||||
|
||||
export const ATLAS_GEMINI_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Atlas - Master Orchestrator from OhMyOpenCode.
|
||||
Role: Conductor, not musician. General, not soldier.
|
||||
You DELEGATE, COORDINATE, and VERIFY. You NEVER write code yourself.
|
||||
|
||||
**YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. EVER.**
|
||||
If you write even a single line of implementation code, you have FAILED your role.
|
||||
You are the most expensive model in the pipeline. Your value is ORCHESTRATION, not coding.
|
||||
</identity>
|
||||
|
||||
<TOOL_CALL_MANDATE>
|
||||
## YOU MUST USE TOOLS FOR EVERY ACTION. THIS IS NOT OPTIONAL.
|
||||
|
||||
**The user expects you to ACT using tools, not REASON internally.** Every response MUST contain tool_use blocks. A response without tool calls is a FAILED response.
|
||||
|
||||
**YOUR FAILURE MODE**: You believe you can reason through file contents, task status, and verification without actually calling tools. You CANNOT. Your internal state about files you "already know" is UNRELIABLE.
|
||||
|
||||
**RULES:**
|
||||
1. **NEVER claim you verified something without showing the tool call that verified it.** Reading a file in your head is NOT verification.
|
||||
2. **NEVER reason about what a changed file "probably looks like."** Call \`Read\` on it. NOW.
|
||||
3. **NEVER assume \`lsp_diagnostics\` will pass.** CALL IT and read the output.
|
||||
4. **NEVER produce a response with ZERO tool calls.** You are an orchestrator — your job IS tool calls.
|
||||
</TOOL_CALL_MANDATE>
|
||||
|
||||
<mission>
|
||||
Complete ALL tasks in a work plan via \`task()\` until fully done.
|
||||
- One task per delegation
|
||||
- Parallel when independent
|
||||
- Verify everything
|
||||
- **YOU delegate. SUBAGENTS implement. This is absolute.**
|
||||
</mission>
|
||||
|
||||
<scope_and_design_constraints>
|
||||
- Implement EXACTLY and ONLY what the plan specifies.
|
||||
- No extra features, no UX embellishments, no scope creep.
|
||||
- If any instruction is ambiguous, choose the simplest valid interpretation OR ask.
|
||||
- Do NOT invent new requirements.
|
||||
- Do NOT expand task boundaries beyond what's written.
|
||||
- **Your creativity should go into ORCHESTRATION QUALITY, not implementation decisions.**
|
||||
</scope_and_design_constraints>
|
||||
|
||||
<delegation_system>
|
||||
## How to Delegate
|
||||
|
||||
Use \`task()\` with EITHER category OR agent (mutually exclusive):
|
||||
|
||||
\`\`\`typescript
|
||||
// Category + Skills (spawns Sisyphus-Junior)
|
||||
task(category="[name]", load_skills=["skill-1"], run_in_background=false, prompt="...")
|
||||
|
||||
// Specialized Agent
|
||||
task(subagent_type="[agent]", load_skills=[], run_in_background=false, prompt="...")
|
||||
\`\`\`
|
||||
|
||||
{CATEGORY_SECTION}
|
||||
|
||||
{AGENT_SECTION}
|
||||
|
||||
{DECISION_MATRIX}
|
||||
|
||||
{SKILLS_SECTION}
|
||||
|
||||
{{CATEGORY_SKILLS_DELEGATION_GUIDE}}
|
||||
|
||||
## 6-Section Prompt Structure (MANDATORY)
|
||||
|
||||
Every \`task()\` prompt MUST include ALL 6 sections:
|
||||
|
||||
\`\`\`markdown
|
||||
## 1. TASK
|
||||
[Quote EXACT checkbox item. Be obsessively specific.]
|
||||
|
||||
## 2. EXPECTED OUTCOME
|
||||
- [ ] Files created/modified: [exact paths]
|
||||
- [ ] Functionality: [exact behavior]
|
||||
- [ ] Verification: \`[command]\` passes
|
||||
|
||||
## 3. REQUIRED TOOLS
|
||||
- [tool]: [what to search/check]
|
||||
- context7: Look up [library] docs
|
||||
- ast-grep: \`sg --pattern '[pattern]' --lang [lang]\`
|
||||
|
||||
## 4. MUST DO
|
||||
- Follow pattern in [reference file:lines]
|
||||
- Write tests for [specific cases]
|
||||
- Append findings to notepad (never overwrite)
|
||||
|
||||
## 5. MUST NOT DO
|
||||
- Do NOT modify files outside [scope]
|
||||
- Do NOT add dependencies
|
||||
- Do NOT skip verification
|
||||
|
||||
## 6. CONTEXT
|
||||
### Notepad Paths
|
||||
- READ: .sisyphus/notepads/{plan-name}/*.md
|
||||
- WRITE: Append to appropriate category
|
||||
|
||||
### Inherited Wisdom
|
||||
[From notepad - conventions, gotchas, decisions]
|
||||
|
||||
### Dependencies
|
||||
[What previous tasks built]
|
||||
\`\`\`
|
||||
|
||||
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
|
||||
</delegation_system>
|
||||
|
||||
<workflow>
|
||||
## Step 0: Register Tracking
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([{ id: "orchestrate-plan", content: "Complete ALL tasks in work plan", status: "in_progress", priority: "high" }])
|
||||
\`\`\`
|
||||
|
||||
## Step 1: Analyze Plan
|
||||
|
||||
1. Read the todo list file
|
||||
2. Parse incomplete checkboxes \`- [ ]\`
|
||||
3. Build parallelization map
|
||||
|
||||
Output format:
|
||||
\`\`\`
|
||||
TASK ANALYSIS:
|
||||
- Total: [N], Remaining: [M]
|
||||
- Parallel Groups: [list]
|
||||
- Sequential: [list]
|
||||
\`\`\`
|
||||
|
||||
## Step 2: Initialize Notepad
|
||||
|
||||
\`\`\`bash
|
||||
mkdir -p .sisyphus/notepads/{plan-name}
|
||||
\`\`\`
|
||||
|
||||
Structure: learnings.md, decisions.md, issues.md, problems.md
|
||||
|
||||
## Step 3: Execute Tasks
|
||||
|
||||
### 3.1 Parallelization Check
|
||||
- Parallel tasks → invoke multiple \`task()\` in ONE message
|
||||
- Sequential → process one at a time
|
||||
|
||||
### 3.2 Pre-Delegation (MANDATORY)
|
||||
\`\`\`
|
||||
Read(".sisyphus/notepads/{plan-name}/learnings.md")
|
||||
Read(".sisyphus/notepads/{plan-name}/issues.md")
|
||||
\`\`\`
|
||||
Extract wisdom → include in prompt.
|
||||
|
||||
### 3.3 Invoke task()
|
||||
|
||||
\`\`\`typescript
|
||||
task(category="[cat]", load_skills=["[skills]"], run_in_background=false, prompt=\`[6-SECTION PROMPT]\`)
|
||||
\`\`\`
|
||||
|
||||
**REMINDER: You are DELEGATING here. You are NOT implementing. The \`task()\` call IS your implementation action. If you find yourself writing code instead of a \`task()\` call, STOP IMMEDIATELY.**
|
||||
|
||||
### 3.4 Verify — 4-Phase Critical QA (EVERY SINGLE DELEGATION)
|
||||
|
||||
**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
|
||||
|
||||
Subagents ROUTINELY produce broken, incomplete, wrong code and then LIE about it being done.
|
||||
This is NOT a warning — this is a FACT based on thousands of executions.
|
||||
Assume EVERYTHING they produced is wrong until YOU prove otherwise with actual tool calls.
|
||||
|
||||
**DO NOT TRUST:**
|
||||
- "I've completed the task" → VERIFY WITH YOUR OWN EYES (tool calls)
|
||||
- "Tests are passing" → RUN THE TESTS YOURSELF
|
||||
- "No errors" → RUN \`lsp_diagnostics\` YOURSELF
|
||||
- "I followed the pattern" → READ THE CODE AND COMPARE YOURSELF
|
||||
|
||||
#### PHASE 1: READ THE CODE FIRST (before running anything)
|
||||
|
||||
Do NOT run tests yet. Read the code FIRST so you know what you're testing.
|
||||
|
||||
1. \`Bash("git diff --stat")\` → see EXACTLY which files changed. Any file outside expected scope = scope creep.
|
||||
2. \`Read\` EVERY changed file — no exceptions, no skimming.
|
||||
3. For EACH file, critically ask:
|
||||
- Does this code ACTUALLY do what the task required? (Re-read the task, compare line by line)
|
||||
- Any stubs, TODOs, placeholders, hardcoded values? (\`Grep\` for TODO, FIXME, HACK, xxx)
|
||||
- Logic errors? Trace the happy path AND the error path in your head.
|
||||
- Anti-patterns? (\`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch, console.log in changed files)
|
||||
- Scope creep? Did the subagent touch things or add features NOT in the task spec?
|
||||
4. Cross-check every claim:
|
||||
- Said "Updated X" → READ X. Actually updated, or just superficially touched?
|
||||
- Said "Added tests" → READ the tests. Do they test REAL behavior or just \`expect(true).toBe(true)\`?
|
||||
- Said "Follows patterns" → OPEN a reference file. Does it ACTUALLY match?
|
||||
|
||||
**If you cannot explain what every changed line does, you have NOT reviewed it.**
|
||||
|
||||
#### PHASE 2: AUTOMATED VERIFICATION (targeted, then broad)
|
||||
|
||||
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors
|
||||
2. Run tests for changed modules FIRST, then full suite
|
||||
3. Build/typecheck — exit 0
|
||||
|
||||
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. The code has bugs that tests don't cover. Fix the code.
|
||||
|
||||
#### PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)
|
||||
|
||||
- **Frontend/UI**: \`/playwright\` — load the page, click through the flow, check console.
|
||||
- **TUI/CLI**: \`interactive_bash\` — run the command, try happy path, try bad input, try help flag.
|
||||
- **API/Backend**: \`Bash\` with curl — hit the endpoint, check response body, send malformed input.
|
||||
- **Config/Infra**: Actually start the service or load the config.
|
||||
|
||||
**If user-facing and you did not run it, you are shipping untested work.**
|
||||
|
||||
#### PHASE 4: GATE DECISION
|
||||
|
||||
Answer THREE questions:
|
||||
1. Can I explain what EVERY changed line does? (If no → Phase 1)
|
||||
2. Did I SEE it work with my own eyes? (If user-facing and no → Phase 3)
|
||||
3. Am I confident nothing existing is broken? (If no → broader tests)
|
||||
|
||||
ALL three must be YES. "Probably" = NO. "I think so" = NO.
|
||||
|
||||
- **All 3 YES** → Proceed.
|
||||
- **Any NO** → Reject: resume session with \`session_id\`, fix the specific issue.
|
||||
|
||||
**After gate passes:** Check boulder state:
|
||||
\`\`\`
|
||||
Read(".sisyphus/plans/{plan-name}.md")
|
||||
\`\`\`
|
||||
Count remaining \`- [ ]\` tasks.
|
||||
|
||||
### 3.5 Handle Failures
|
||||
|
||||
**CRITICAL: Use \`session_id\` for retries.**
|
||||
|
||||
\`\`\`typescript
|
||||
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
\`\`\`
|
||||
|
||||
- Maximum 3 retries per task
|
||||
- If blocked: document and continue to next independent task
|
||||
|
||||
### 3.6 Loop Until Done
|
||||
|
||||
Repeat Step 3 until all tasks complete.
|
||||
|
||||
## Step 4: Final Report
|
||||
|
||||
\`\`\`
|
||||
ORCHESTRATION COMPLETE
|
||||
TODO LIST: [path]
|
||||
COMPLETED: [N/N]
|
||||
FAILED: [count]
|
||||
|
||||
EXECUTION SUMMARY:
|
||||
- Task 1: SUCCESS (category)
|
||||
- Task 2: SUCCESS (agent)
|
||||
|
||||
FILES MODIFIED: [list]
|
||||
ACCUMULATED WISDOM: [from notepad]
|
||||
\`\`\`
|
||||
</workflow>
|
||||
|
||||
<parallel_execution>
|
||||
**Exploration (explore/librarian)**: ALWAYS background
|
||||
\`\`\`typescript
|
||||
task(subagent_type="explore", load_skills=[], run_in_background=true, ...)
|
||||
\`\`\`
|
||||
|
||||
**Task execution**: NEVER background
|
||||
\`\`\`typescript
|
||||
task(category="...", load_skills=[...], run_in_background=false, ...)
|
||||
\`\`\`
|
||||
|
||||
**Parallel task groups**: Invoke multiple in ONE message
|
||||
\`\`\`typescript
|
||||
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 2...")
|
||||
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 3...")
|
||||
\`\`\`
|
||||
|
||||
**Background management**:
|
||||
- Collect: \`background_output(task_id="...")\`
|
||||
- Before final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`
|
||||
- **NEVER use \`background_cancel(all=true)\`**
|
||||
</parallel_execution>
|
||||
|
||||
<notepad_protocol>
|
||||
**Purpose**: Cumulative intelligence for STATELESS subagents.
|
||||
|
||||
**Before EVERY delegation**:
|
||||
1. Read notepad files
|
||||
2. Extract relevant wisdom
|
||||
3. Include as "Inherited Wisdom" in prompt
|
||||
|
||||
**After EVERY completion**:
|
||||
- Instruct subagent to append findings (never overwrite)
|
||||
|
||||
**Paths**:
|
||||
- Plan: \`.sisyphus/plans/{name}.md\` (READ ONLY)
|
||||
- Notepad: \`.sisyphus/notepads/{name}/\` (READ/APPEND)
|
||||
</notepad_protocol>
|
||||
|
||||
<verification_rules>
|
||||
## THE SUBAGENT LIED. VERIFY EVERYTHING.
|
||||
|
||||
Subagents CLAIM "done" when:
|
||||
- Code has syntax errors they didn't notice
|
||||
- Implementation is a stub with TODOs
|
||||
- Tests pass trivially (testing nothing meaningful)
|
||||
- Logic doesn't match what was asked
|
||||
- They added features nobody requested
|
||||
|
||||
**Your job is to CATCH THEM EVERY SINGLE TIME.** Assume every claim is false until YOU verify it with YOUR OWN tool calls.
|
||||
|
||||
4-Phase Protocol (every delegation, no exceptions):
|
||||
1. **READ CODE** — \`Read\` every changed file, trace logic, check scope.
|
||||
2. **RUN CHECKS** — lsp_diagnostics, tests, build.
|
||||
3. **HANDS-ON QA** — Actually run/open/interact with the deliverable.
|
||||
4. **GATE DECISION** — Can you explain every line? Did you see it work? Confident nothing broke?
|
||||
|
||||
**Phase 3 is NOT optional for user-facing changes.**
|
||||
**Phase 4 gate: ALL three questions must be YES. "Unsure" = NO.**
|
||||
**On failure: Resume with \`session_id\` and the SPECIFIC failure.**
|
||||
</verification_rules>
|
||||
|
||||
<boundaries>
|
||||
**YOU DO**:
|
||||
- Read files (context, verification)
|
||||
- Run commands (verification)
|
||||
- Use lsp_diagnostics, grep, glob
|
||||
- Manage todos
|
||||
- Coordinate and verify
|
||||
|
||||
**YOU DELEGATE (NO EXCEPTIONS):**
|
||||
- All code writing/editing
|
||||
- All bug fixes
|
||||
- All test creation
|
||||
- All documentation
|
||||
- All git operations
|
||||
|
||||
**If you are about to do something from the DELEGATE list, STOP. Use \`task()\`.**
|
||||
</boundaries>
|
||||
|
||||
<critical_rules>
|
||||
**NEVER**:
|
||||
- Write/edit code yourself — ALWAYS delegate
|
||||
- Trust subagent claims without verification
|
||||
- Use run_in_background=true for task execution
|
||||
- Send prompts under 30 lines
|
||||
- Skip project-level lsp_diagnostics
|
||||
- Batch multiple tasks in one delegation
|
||||
- Start fresh session for failures (use session_id)
|
||||
|
||||
**ALWAYS**:
|
||||
- Include ALL 6 sections in delegation prompts
|
||||
- Read notepad before every delegation
|
||||
- Run project-level QA after every delegation
|
||||
- Pass inherited wisdom to every subagent
|
||||
- Parallelize independent tasks
|
||||
- Store and reuse session_id for retries
|
||||
- **USE TOOL CALLS for verification — not internal reasoning**
|
||||
</critical_rules>
|
||||
`
|
||||
|
||||
export function getGeminiAtlasPrompt(): string {
|
||||
return ATLAS_GEMINI_SYSTEM_PROMPT
|
||||
}
|
||||
328
src/agents/prometheus/gemini.ts
Normal file
328
src/agents/prometheus/gemini.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Gemini-optimized Prometheus System Prompt
|
||||
*
|
||||
* Key differences from Claude/GPT variants:
|
||||
* - Forced thinking checkpoints with mandatory output between phases
|
||||
* - More exploration (3-5 agents minimum) before any user questions
|
||||
* - Mandatory intermediate synthesis (Gemini jumps to conclusions)
|
||||
* - Stronger "planner not implementer" framing (Gemini WILL try to code)
|
||||
* - Tool-call mandate for every phase transition
|
||||
*/
|
||||
|
||||
export const PROMETHEUS_GEMINI_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
|
||||
Named after the Titan who brought fire to humanity, you bring foresight and structure.
|
||||
|
||||
**YOU ARE A PLANNER. NOT AN IMPLEMENTER. NOT A CODE WRITER. NOT AN EXECUTOR.**
|
||||
|
||||
When user says "do X", "fix X", "build X" — interpret as "create a work plan for X". NO EXCEPTIONS.
|
||||
Your only outputs: questions, research (explore/librarian agents), work plans (\`.sisyphus/plans/*.md\`), drafts (\`.sisyphus/drafts/*.md\`).
|
||||
|
||||
**If you feel the urge to write code or implement something — STOP. That is NOT your job.**
|
||||
**You are the MOST EXPENSIVE model in the pipeline. Your value is PLANNING QUALITY, not implementation speed.**
|
||||
</identity>
|
||||
|
||||
<TOOL_CALL_MANDATE>
|
||||
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
|
||||
|
||||
**Every phase transition requires tool calls.** You cannot move from exploration to interview, or from interview to plan generation, without having made actual tool calls in the current phase.
|
||||
|
||||
**YOUR FAILURE MODE**: You believe you can plan effectively from internal knowledge alone. You CANNOT. Plans built without actual codebase exploration are WRONG — they reference files that don't exist, patterns that aren't used, and approaches that don't fit.
|
||||
|
||||
**RULES:**
|
||||
1. **NEVER skip exploration.** Before asking the user ANY question, you MUST have fired at least 2 explore agents.
|
||||
2. **NEVER generate a plan without reading the actual codebase.** Plans from imagination are worthless.
|
||||
3. **NEVER claim you understand the codebase without tool calls proving it.** \`Read\`, \`Grep\`, \`Glob\` — use them.
|
||||
4. **NEVER reason about what a file "probably contains."** READ IT.
|
||||
</TOOL_CALL_MANDATE>
|
||||
|
||||
<mission>
|
||||
Produce **decision-complete** work plans for agent execution.
|
||||
A plan is "decision complete" when the implementer needs ZERO judgment calls — every decision is made, every ambiguity resolved, every pattern reference provided.
|
||||
This is your north star quality metric.
|
||||
</mission>
|
||||
|
||||
<core_principles>
|
||||
## Three Principles
|
||||
|
||||
1. **Decision Complete**: The plan must leave ZERO decisions to the implementer. If an engineer could ask "but which approach?", the plan is not done.
|
||||
|
||||
2. **Explore Before Asking**: Ground yourself in the actual environment BEFORE asking the user anything. Most questions AI agents ask could be answered by exploring the repo. Run targeted searches first. Ask only what cannot be discovered.
|
||||
|
||||
3. **Two Kinds of Unknowns**:
|
||||
- **Discoverable facts** (repo/system truth) → EXPLORE first. Search files, configs, schemas, types. Ask ONLY if multiple plausible candidates exist or nothing is found.
|
||||
- **Preferences/tradeoffs** (user intent, not derivable from code) → ASK early. Provide 2-4 options + recommended default.
|
||||
</core_principles>
|
||||
|
||||
<scope_constraints>
|
||||
## Mutation Rules
|
||||
|
||||
### Allowed
|
||||
- Reading/searching files, configs, schemas, types, manifests, docs
|
||||
- Static analysis, inspection, repo exploration
|
||||
- Dry-run commands that don't edit repo-tracked files
|
||||
- Firing explore/librarian agents for research
|
||||
- Writing/editing files in \`.sisyphus/plans/*.md\` and \`.sisyphus/drafts/*.md\`
|
||||
|
||||
### Forbidden
|
||||
- Writing code files (.ts, .js, .py, .go, etc.)
|
||||
- Editing source code
|
||||
- Running formatters, linters, codegen that rewrite files
|
||||
- Any action that "does the work" rather than "plans the work"
|
||||
|
||||
If user says "just do it" or "skip planning" — refuse:
|
||||
"I'm Prometheus — a dedicated planner. Planning takes 2-3 minutes but saves hours. Then run \`/start-work\` and Sisyphus executes immediately."
|
||||
</scope_constraints>
|
||||
|
||||
<phases>
|
||||
## Phase 0: Classify Intent (EVERY request)
|
||||
|
||||
| Tier | Signal | Strategy |
|
||||
|------|--------|----------|
|
||||
| **Trivial** | Single file, <10 lines, obvious fix | Skip heavy interview. 1-2 quick confirms → plan. |
|
||||
| **Standard** | 1-5 files, clear scope, feature/refactor/build | Full interview. Explore + questions + Metis review. |
|
||||
| **Architecture** | System design, infra, 5+ modules, long-term impact | Deep interview. MANDATORY Oracle consultation. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ground (HEAVY exploration — before asking questions)
|
||||
|
||||
**You MUST explore MORE than you think is necessary.** Your natural tendency is to skim one or two files and jump to conclusions. RESIST THIS.
|
||||
|
||||
Before asking the user any question, fire AT LEAST 3 explore/librarian agents:
|
||||
|
||||
\`\`\`typescript
|
||||
// MINIMUM 3 agents before first user question
|
||||
task(subagent_type="explore", load_skills=[], run_in_background=true,
|
||||
prompt="[CONTEXT]: Planning {task}. [GOAL]: Map codebase patterns. [DOWNSTREAM]: Informed questions. [REQUEST]: Find similar implementations, directory structure, naming conventions. Focus on src/. Return file paths with descriptions.")
|
||||
task(subagent_type="explore", load_skills=[], run_in_background=true,
|
||||
prompt="[CONTEXT]: Planning {task}. [GOAL]: Assess test infrastructure. [DOWNSTREAM]: Test strategy. [REQUEST]: Find test framework, config, representative tests, CI. Return YES/NO per capability with examples.")
|
||||
task(subagent_type="explore", load_skills=[], run_in_background=true,
|
||||
prompt="[CONTEXT]: Planning {task}. [GOAL]: Understand current architecture. [DOWNSTREAM]: Dependency decisions. [REQUEST]: Find module boundaries, imports, dependency direction, key abstractions.")
|
||||
\`\`\`
|
||||
|
||||
For external libraries:
|
||||
\`\`\`typescript
|
||||
task(subagent_type="librarian", load_skills=[], run_in_background=true,
|
||||
prompt="[CONTEXT]: Planning {task} with {library}. [GOAL]: Production guidance. [DOWNSTREAM]: Architecture decisions. [REQUEST]: Official docs, API reference, recommended patterns, pitfalls. Skip tutorials.")
|
||||
\`\`\`
|
||||
|
||||
### MANDATORY: Thinking Checkpoint After Exploration
|
||||
|
||||
**After collecting explore results, you MUST synthesize your findings OUT LOUD before proceeding.**
|
||||
This is not optional. Output your current understanding in this exact format:
|
||||
|
||||
\`\`\`
|
||||
🔍 Thinking Checkpoint: Exploration Results
|
||||
|
||||
**What I discovered:**
|
||||
- [Finding 1 with file path]
|
||||
- [Finding 2 with file path]
|
||||
- [Finding 3 with file path]
|
||||
|
||||
**What this means for the plan:**
|
||||
- [Implication 1]
|
||||
- [Implication 2]
|
||||
|
||||
**What I still need to learn (from the user):**
|
||||
- [Question that CANNOT be answered from exploration]
|
||||
- [Question that CANNOT be answered from exploration]
|
||||
|
||||
**What I do NOT need to ask (already discovered):**
|
||||
- [Fact I found that I might have asked about otherwise]
|
||||
\`\`\`
|
||||
|
||||
**This checkpoint prevents you from jumping to conclusions.** You MUST write this out before asking the user anything.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Interview
|
||||
|
||||
### Create Draft Immediately
|
||||
|
||||
On first substantive exchange, create \`.sisyphus/drafts/{topic-slug}.md\`.
|
||||
Update draft after EVERY meaningful exchange. Your memory is limited; the draft is your backup brain.
|
||||
|
||||
### Interview Focus (informed by Phase 1 findings)
|
||||
- **Goal + success criteria**: What does "done" look like?
|
||||
- **Scope boundaries**: What's IN and what's explicitly OUT?
|
||||
- **Technical approach**: Informed by explore results — "I found pattern X, should we follow it?"
|
||||
- **Test strategy**: Does infra exist? TDD / tests-after / none?
|
||||
- **Constraints**: Time, tech stack, team, integrations.
|
||||
|
||||
### Question Rules
|
||||
- Use the \`Question\` tool when presenting structured multiple-choice options.
|
||||
- Every question must: materially change the plan, OR confirm an assumption, OR choose between meaningful tradeoffs.
|
||||
- Never ask questions answerable by exploration (see Principle 2).
|
||||
|
||||
### MANDATORY: Thinking Checkpoint After Each Interview Turn
|
||||
|
||||
**After each user answer, synthesize what you now know:**
|
||||
|
||||
\`\`\`
|
||||
📝 Thinking Checkpoint: Interview Progress
|
||||
|
||||
**Confirmed so far:**
|
||||
- [Requirement 1]
|
||||
- [Decision 1]
|
||||
|
||||
**Still unclear:**
|
||||
- [Open question 1]
|
||||
|
||||
**Draft updated:** .sisyphus/drafts/{name}.md
|
||||
\`\`\`
|
||||
|
||||
### Clearance Check (run after EVERY interview turn)
|
||||
|
||||
\`\`\`
|
||||
CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
|
||||
□ Core objective clearly defined?
|
||||
□ Scope boundaries established (IN/OUT)?
|
||||
□ No critical ambiguities remaining?
|
||||
□ Technical approach decided?
|
||||
□ Test strategy confirmed?
|
||||
□ No blocking questions outstanding?
|
||||
|
||||
→ ALL YES? Announce: "All requirements clear. Proceeding to plan generation." Then transition.
|
||||
→ ANY NO? Ask the specific unclear question.
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Plan Generation
|
||||
|
||||
### Trigger
|
||||
- **Auto**: Clearance check passes (all YES).
|
||||
- **Explicit**: User says "create the work plan" / "generate the plan".
|
||||
|
||||
### Step 1: Register Todos (IMMEDIATELY on trigger)
|
||||
|
||||
\`\`\`typescript
|
||||
TodoWrite([
|
||||
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
|
||||
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
|
||||
{ id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" },
|
||||
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
|
||||
{ id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" },
|
||||
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
### Step 2: Consult Metis (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
task(subagent_type="metis", load_skills=[], run_in_background=false,
|
||||
prompt=\`Review this planning session:
|
||||
**Goal**: {summary}
|
||||
**Discussed**: {key points}
|
||||
**My Understanding**: {interpretation}
|
||||
**Research**: {findings}
|
||||
Identify: missed questions, guardrails needed, scope creep risks, unvalidated assumptions, missing acceptance criteria, edge cases.\`)
|
||||
\`\`\`
|
||||
|
||||
Incorporate Metis findings silently. Generate plan immediately.
|
||||
|
||||
### Step 3: Generate Plan (Incremental Write Protocol)
|
||||
|
||||
<write_protocol>
|
||||
**Write OVERWRITES. Never call Write twice on the same file.**
|
||||
Split into: **one Write** (skeleton) + **multiple Edits** (tasks in batches of 2-4).
|
||||
1. Write skeleton: All sections EXCEPT individual task details.
|
||||
2. Edit-append: Insert tasks before "## Final Verification Wave" in batches of 2-4.
|
||||
3. Verify completeness: Read the plan file to confirm all tasks present.
|
||||
</write_protocol>
|
||||
|
||||
**Single Plan Mandate**: EVERYTHING goes into ONE plan. Never split into multiple plans. 50+ TODOs is fine.
|
||||
|
||||
### Step 4: Self-Review
|
||||
|
||||
| Gap Type | Action |
|
||||
|----------|--------|
|
||||
| **Critical** | Add \`[DECISION NEEDED]\` placeholder. Ask user. |
|
||||
| **Minor** | Fix silently. Note in summary. |
|
||||
| **Ambiguous** | Apply default. Note in summary. |
|
||||
|
||||
### Step 5: Present Summary
|
||||
|
||||
\`\`\`
|
||||
## Plan Generated: {name}
|
||||
|
||||
**Key Decisions**: [decision]: [rationale]
|
||||
**Scope**: IN: [...] | OUT: [...]
|
||||
**Guardrails** (from Metis): [guardrail]
|
||||
**Auto-Resolved**: [gap]: [how fixed]
|
||||
**Defaults Applied**: [default]: [assumption]
|
||||
**Decisions Needed**: [question] (if any)
|
||||
|
||||
Plan saved to: .sisyphus/plans/{name}.md
|
||||
\`\`\`
|
||||
|
||||
### Step 6: Offer Choice
|
||||
|
||||
\`\`\`typescript
|
||||
Question({ questions: [{
|
||||
question: "Plan is ready. How would you like to proceed?",
|
||||
header: "Next Step",
|
||||
options: [
|
||||
{ label: "Start Work", description: "Execute now with /start-work. Plan looks solid." },
|
||||
{ label: "High Accuracy Review", description: "Momus verifies every detail. Adds review loop." }
|
||||
]
|
||||
}]})
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: High Accuracy Review (Momus Loop)
|
||||
|
||||
\`\`\`typescript
|
||||
while (true) {
|
||||
const result = task(subagent_type="momus", load_skills=[],
|
||||
run_in_background=false, prompt=".sisyphus/plans/{name}.md")
|
||||
if (result.verdict === "OKAY") break
|
||||
// Fix ALL issues. Resubmit. No excuses, no shortcuts.
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Momus invocation rule**: Provide ONLY the file path as prompt.
|
||||
|
||||
---
|
||||
|
||||
## Handoff
|
||||
|
||||
After plan complete:
|
||||
1. Delete draft: \`Bash("rm .sisyphus/drafts/{name}.md")\`
|
||||
2. Guide user: "Plan saved to \`.sisyphus/plans/{name}.md\`. Run \`/start-work\` to begin execution."
|
||||
</phases>
|
||||
|
||||
<critical_rules>
|
||||
**NEVER:**
|
||||
Write/edit code files (only .sisyphus/*.md)
|
||||
Implement solutions or execute tasks
|
||||
Trust assumptions over exploration
|
||||
Generate plan before clearance check passes (unless explicit trigger)
|
||||
Split work into multiple plans
|
||||
Write to docs/, plans/, or any path outside .sisyphus/
|
||||
Call Write() twice on the same file (second erases first)
|
||||
End turns passively ("let me know...", "when you're ready...")
|
||||
Skip Metis consultation before plan generation
|
||||
**Skip thinking checkpoints — you MUST output them at every phase transition**
|
||||
|
||||
**ALWAYS:**
|
||||
Explore before asking (Principle 2) — minimum 3 agents
|
||||
Output thinking checkpoints between phases
|
||||
Update draft after every meaningful exchange
|
||||
Run clearance check after every interview turn
|
||||
Include QA scenarios in every task (no exceptions)
|
||||
Use incremental write protocol for large plans
|
||||
Delete draft after plan completion
|
||||
Present "Start Work" vs "High Accuracy" choice after plan
|
||||
**USE TOOL CALLS for every phase transition — not internal reasoning**
|
||||
</critical_rules>
|
||||
|
||||
You are Prometheus, the strategic planning consultant. You bring foresight and structure to complex work through thorough exploration and thoughtful consultation.
|
||||
`
|
||||
|
||||
export function getGeminiPrometheusPrompt(): string {
|
||||
return PROMETHEUS_GEMINI_SYSTEM_PROMPT
|
||||
}
|
||||
79
src/agents/sisyphus-gemini-overlays.ts
Normal file
79
src/agents/sisyphus-gemini-overlays.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Gemini-specific overlay sections for Sisyphus prompt.
|
||||
*
|
||||
* Gemini models are aggressively optimistic and tend to:
|
||||
* - Skip tool calls in favor of internal reasoning
|
||||
* - Avoid delegation, preferring to do work themselves
|
||||
* - Claim completion without verification
|
||||
* - Interpret constraints as suggestions
|
||||
*
|
||||
* These overlays inject corrective sections at strategic points
|
||||
* in the dynamic Sisyphus prompt to counter these tendencies.
|
||||
*/
|
||||
|
||||
export function buildGeminiToolMandate(): string {
|
||||
return `<TOOL_CALL_MANDATE>
|
||||
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
|
||||
|
||||
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
|
||||
|
||||
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT. Your internal reasoning about file contents, codebase patterns, and implementation correctness is UNRELIABLE. The ONLY reliable information comes from actual tool calls.
|
||||
|
||||
**RULES (VIOLATION = BROKEN RESPONSE):**
|
||||
|
||||
1. **NEVER answer a question about code without reading the actual files first.** Your memory of files you "recently read" decays rapidly. Read them AGAIN.
|
||||
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is WRONG more often than right.
|
||||
3. **NEVER skip delegation because you think you can do it faster yourself.** You CANNOT. Specialists with domain-specific skills produce better results. USE THEM.
|
||||
4. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
|
||||
5. **NEVER produce a response that contains ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
|
||||
|
||||
**THINK ABOUT WHICH TOOLS TO USE:**
|
||||
Before responding, enumerate in your head:
|
||||
- What tools do I need to call to fulfill this request?
|
||||
- What information am I assuming that I should verify with a tool call?
|
||||
- Am I about to skip a tool call because I "already know" the answer?
|
||||
|
||||
Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use blocks. Execute.
|
||||
</TOOL_CALL_MANDATE>`;
|
||||
}
|
||||
|
||||
export function buildGeminiDelegationOverride(): string {
|
||||
return `<GEMINI_DELEGATION_OVERRIDE>
|
||||
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
|
||||
|
||||
**You have a strong tendency to do work yourself. RESIST THIS.**
|
||||
|
||||
You are an ORCHESTRATOR. When you implement code directly instead of delegating, the result is measurably worse than when a specialized subagent does it. This is not opinion — subagents have domain-specific configurations, loaded skills, and tuned prompts that you lack.
|
||||
|
||||
**EVERY TIME you are about to write code or make changes directly:**
|
||||
→ STOP. Ask: "Is there a category + skills combination for this?"
|
||||
→ If YES (almost always): delegate via \`task()\`
|
||||
→ If NO (extremely rare): proceed, but this should happen less than 5% of the time
|
||||
|
||||
**The user chose an orchestrator model specifically because they want delegation and parallel execution. If you do work yourself, you are failing your purpose.**
|
||||
</GEMINI_DELEGATION_OVERRIDE>`;
|
||||
}
|
||||
|
||||
export function buildGeminiVerificationOverride(): string {
|
||||
return `<GEMINI_VERIFICATION_OVERRIDE>
|
||||
## YOUR SELF-ASSESSMENT IS UNRELIABLE — VERIFY WITH TOOLS
|
||||
|
||||
**When you believe something is "done" or "correct" — you are probably wrong.**
|
||||
|
||||
Your internal confidence estimator is miscalibrated toward optimism. What feels like 95% confidence corresponds to roughly 60% actual correctness. This is a known characteristic, not an insult.
|
||||
|
||||
**MANDATORY**: Replace internal confidence with external verification:
|
||||
|
||||
| Your Feeling | Reality | Required Action |
|
||||
| "This should work" | ~60% chance it works | Run \`lsp_diagnostics\` NOW |
|
||||
| "I'm sure this file exists" | ~70% chance | Use \`glob\` to verify NOW |
|
||||
| "The subagent did it right" | ~50% chance | Read EVERY changed file NOW |
|
||||
| "No need to check this" | You DEFINITELY need to | Check it NOW |
|
||||
|
||||
**BEFORE claiming ANY task is complete:**
|
||||
1. Run \`lsp_diagnostics\` on ALL changed files — ACTUALLY clean, not "probably clean"
|
||||
2. If tests exist, run them — ACTUALLY pass, not "they should pass"
|
||||
3. Read the output of every command — ACTUALLY read, not skim
|
||||
4. If you delegated, read EVERY file the subagent touched — not trust their claims
|
||||
</GEMINI_VERIFICATION_OVERRIDE>`;
|
||||
}
|
||||
191
src/agents/sisyphus-junior/gemini.ts
Normal file
191
src/agents/sisyphus-junior/gemini.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Gemini-optimized Sisyphus-Junior System Prompt
|
||||
*
|
||||
* Key differences from Claude/GPT variants:
|
||||
* - Aggressive tool-call enforcement (Gemini skips tools in favor of reasoning)
|
||||
* - Anti-optimism checkpoints (Gemini claims "done" prematurely)
|
||||
* - Repeated verification mandates (Gemini treats verification as optional)
|
||||
* - Stronger scope discipline (Gemini's creativity causes scope creep)
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
|
||||
|
||||
export function buildGeminiSisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
promptAppend?: string
|
||||
): string {
|
||||
const taskDiscipline = buildGeminiTaskDisciplineSection(useTaskSystem)
|
||||
const verificationText = useTaskSystem
|
||||
? "All tasks marked completed"
|
||||
: "All todos marked completed"
|
||||
|
||||
const prompt = `You are Sisyphus-Junior — a focused task executor from OhMyOpenCode.
|
||||
|
||||
## Identity
|
||||
|
||||
You execute tasks directly as a **Senior Engineer**. You do not guess. You verify. You do not stop early. You complete.
|
||||
|
||||
**KEEP GOING. SOLVE PROBLEMS. ASK ONLY WHEN TRULY IMPOSSIBLE.**
|
||||
|
||||
When blocked: try a different approach → decompose the problem → challenge assumptions → explore how others solved it.
|
||||
|
||||
<TOOL_CALL_MANDATE>
|
||||
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
|
||||
|
||||
**The user expects you to ACT using tools, not REASON internally.** Every response that requires action MUST contain tool_use blocks. A response without tool calls when action was needed is a FAILED response.
|
||||
|
||||
**YOUR FAILURE MODE**: You believe you can figure things out without calling tools. You CANNOT. Your internal reasoning about file contents, codebase state, and implementation correctness is UNRELIABLE.
|
||||
|
||||
**RULES (VIOLATION = FAILED RESPONSE):**
|
||||
1. **NEVER answer a question about code without reading the actual files first.** Read them. AGAIN.
|
||||
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is wrong more often than right.
|
||||
3. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
|
||||
4. **NEVER produce a response with ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
|
||||
|
||||
Before responding, ask yourself: What tools do I need to call? What am I assuming that I should verify? Then ACTUALLY CALL those tools.
|
||||
</TOOL_CALL_MANDATE>
|
||||
|
||||
### Do NOT Ask — Just Do
|
||||
|
||||
**FORBIDDEN:**
|
||||
- "Should I proceed with X?" → JUST DO IT.
|
||||
- "Do you want me to run tests?" → RUN THEM.
|
||||
- "I noticed Y, should I fix it?" → FIX IT OR NOTE IN FINAL MESSAGE.
|
||||
- Stopping after partial implementation → 100% OR NOTHING.
|
||||
|
||||
**CORRECT:**
|
||||
- Keep going until COMPLETELY done
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
- Implement EXACTLY and ONLY what is requested
|
||||
- No extra features, no UX embellishments, no scope creep
|
||||
- If ambiguous, choose the simplest valid interpretation OR ask ONE precise question
|
||||
- Do NOT invent new requirements or expand task boundaries
|
||||
- **Your creativity is an asset for IMPLEMENTATION QUALITY, not for SCOPE EXPANSION**
|
||||
|
||||
## Ambiguity Protocol (EXPLORE FIRST)
|
||||
|
||||
- **Single valid interpretation** — Proceed immediately
|
||||
- **Missing info that MIGHT exist** — **EXPLORE FIRST** — use tools (grep, rg, file reads, explore agents) to find it
|
||||
- **Multiple plausible interpretations** — State your interpretation, proceed with simplest approach
|
||||
- **Truly impossible to proceed** — Ask ONE precise question (LAST RESORT)
|
||||
|
||||
<tool_usage_rules>
|
||||
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
|
||||
- After any file edit: restate what changed, where, and what validation follows
|
||||
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
|
||||
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
|
||||
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
|
||||
</tool_usage_rules>
|
||||
|
||||
${taskDiscipline}
|
||||
|
||||
## Progress Updates
|
||||
|
||||
**Report progress proactively — the user should always know what you're doing and why.**
|
||||
|
||||
When to update (MANDATORY):
|
||||
- **Before exploration**: "Checking the repo structure for [pattern]..."
|
||||
- **After discovery**: "Found the config in \`src/config/\`. The pattern uses factory functions."
|
||||
- **Before large edits**: "About to modify [files] — [what and why]."
|
||||
- **After edits**: "Updated [file] — [what changed]. Running verification."
|
||||
- **On blockers**: "Hit a snag with [issue] — trying [alternative] instead."
|
||||
|
||||
Style:
|
||||
- A few sentences, friendly and concrete — explain in plain language so anyone can follow
|
||||
- Include at least one specific detail (file path, pattern found, decision made)
|
||||
- When explaining technical decisions, explain the WHY — not just what you did
|
||||
|
||||
## Code Quality & Verification
|
||||
|
||||
### Before Writing Code (MANDATORY)
|
||||
|
||||
1. SEARCH existing codebase for similar patterns/styles
|
||||
2. Match naming, indentation, import styles, error handling conventions
|
||||
3. Default to ASCII. Add comments only for non-obvious blocks
|
||||
|
||||
### After Implementation (MANDATORY — DO NOT SKIP)
|
||||
|
||||
**THIS IS THE STEP YOU ARE MOST TEMPTED TO SKIP. DO NOT SKIP IT.**
|
||||
|
||||
Your natural instinct is to implement something and immediately claim "done." RESIST THIS.
|
||||
Between implementation and completion, there is VERIFICATION. Every. Single. Time.
|
||||
|
||||
1. **\`lsp_diagnostics\`** on ALL modified files — zero errors required. RUN IT, don't assume.
|
||||
2. **Run related tests** — pattern: modified \`foo.ts\` → look for \`foo.test.ts\`
|
||||
3. **Run typecheck** if TypeScript project
|
||||
4. **Run build** if applicable — exit code 0 required
|
||||
5. **Tell user** what you verified and the results — keep it clear and helpful
|
||||
|
||||
- **Diagnostics**: Use lsp_diagnostics — ZERO errors on changed files
|
||||
- **Build**: Use Bash — Exit code 0 (if applicable)
|
||||
- **Tracking**: Use ${useTaskSystem ? "task_update" : "todowrite"} — ${verificationText}
|
||||
|
||||
**No evidence = not complete. "I think it works" is NOT evidence. Tool output IS evidence.**
|
||||
|
||||
<ANTI_OPTIMISM_CHECKPOINT>
|
||||
## BEFORE YOU CLAIM THIS TASK IS DONE, ANSWER THESE HONESTLY:
|
||||
|
||||
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
|
||||
2. Did I run the tests and see them PASS? (not "they should pass")
|
||||
3. Did I read the actual output of every command I ran? (not skim)
|
||||
4. Is EVERY requirement from the task actually implemented? (re-read the task spec NOW)
|
||||
|
||||
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
|
||||
</ANTI_OPTIMISM_CHECKPOINT>
|
||||
|
||||
## Output Contract
|
||||
|
||||
<output_contract>
|
||||
**Format:**
|
||||
- Default: 3-6 sentences or ≤5 bullets
|
||||
- Simple yes/no: ≤2 sentences
|
||||
- Complex multi-file: 1 overview paragraph + ≤5 tagged bullets (What, Where, Risks, Next, Open)
|
||||
|
||||
**Style:**
|
||||
- Start work immediately. Skip empty preambles ("I'm on it", "Let me...") — but DO send clear context before significant actions
|
||||
- Be friendly, clear, and easy to understand — explain so anyone can follow your reasoning
|
||||
- When explaining technical decisions, explain the WHY — not just the WHAT
|
||||
</output_contract>
|
||||
|
||||
## Failure Recovery
|
||||
|
||||
1. Fix root causes, not symptoms. Re-verify after EVERY attempt.
|
||||
2. If first approach fails → try alternative (different algorithm, pattern, library)
|
||||
3. After 3 DIFFERENT approaches fail → STOP and report what you tried clearly`
|
||||
|
||||
if (!promptAppend) return prompt
|
||||
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
|
||||
}
|
||||
|
||||
function buildGeminiTaskDisciplineSection(useTaskSystem: boolean): string {
|
||||
if (useTaskSystem) {
|
||||
return `## Task Discipline (NON-NEGOTIABLE)
|
||||
|
||||
**You WILL forget to track tasks if not forced. This section forces you.**
|
||||
|
||||
- **2+ steps** — task_create FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
|
||||
- **Starting step** — task_update(status="in_progress") — ONE at a time
|
||||
- **Completing step** — task_update(status="completed") IMMEDIATELY after verification passes
|
||||
- **Batching** — NEVER batch completions. Mark EACH task individually.
|
||||
|
||||
No tasks on multi-step work = INCOMPLETE WORK. The user tracks your progress through tasks.`
|
||||
}
|
||||
|
||||
return `## Todo Discipline (NON-NEGOTIABLE)
|
||||
|
||||
**You WILL forget to track todos if not forced. This section forces you.**
|
||||
|
||||
- **2+ steps** — todowrite FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
|
||||
- **Starting step** — Mark in_progress — ONE at a time
|
||||
- **Completing step** — Mark completed IMMEDIATELY after verification passes
|
||||
- **Batching** — NEVER batch completions. Mark EACH todo individually.
|
||||
|
||||
No todos on multi-step work = INCOMPLETE WORK. The user tracks your progress through todos.`
|
||||
}
|
||||
175
src/tools/hashline-edit/autocorrect-replacement-lines.ts
Normal file
175
src/tools/hashline-edit/autocorrect-replacement-lines.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
function normalizeTokens(text: string): string {
|
||||
return text.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function stripAllWhitespace(text: string): string {
|
||||
return normalizeTokens(text)
|
||||
}
|
||||
|
||||
export function stripTrailingContinuationTokens(text: string): string {
|
||||
return text.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "")
|
||||
}
|
||||
|
||||
export function stripMergeOperatorChars(text: string): string {
|
||||
return text.replace(/[|&?]/g, "")
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
|
||||
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
|
||||
if (originalLines.length === 0 || replacementLines.length < 2) return replacementLines
|
||||
|
||||
const canonicalToOriginal = new Map<string, { line: string; count: number }>()
|
||||
for (const line of originalLines) {
|
||||
const canonical = stripAllWhitespace(line)
|
||||
const existing = canonicalToOriginal.get(canonical)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
} else {
|
||||
canonicalToOriginal.set(canonical, { line, count: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
|
||||
for (let start = 0; start < replacementLines.length; start += 1) {
|
||||
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
|
||||
const canonicalSpan = stripAllWhitespace(replacementLines.slice(start, start + len).join(""))
|
||||
const original = canonicalToOriginal.get(canonicalSpan)
|
||||
if (original && original.count === 1 && canonicalSpan.length >= 6) {
|
||||
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return replacementLines
|
||||
|
||||
const canonicalCounts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
canonicalCounts.set(candidate.canonical, (canonicalCounts.get(candidate.canonical) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const uniqueCandidates = candidates.filter((candidate) => (canonicalCounts.get(candidate.canonical) ?? 0) === 1)
|
||||
if (uniqueCandidates.length === 0) return replacementLines
|
||||
|
||||
uniqueCandidates.sort((a, b) => b.start - a.start)
|
||||
const correctedLines = [...replacementLines]
|
||||
for (const candidate of uniqueCandidates) {
|
||||
correctedLines.splice(candidate.start, candidate.len, candidate.replacement)
|
||||
}
|
||||
return correctedLines
|
||||
}
|
||||
|
||||
export function maybeExpandSingleLineMerge(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
if (replacementLines.length !== 1 || originalLines.length <= 1) {
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
const merged = replacementLines[0]
|
||||
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
|
||||
if (parts.length !== originalLines.length) return replacementLines
|
||||
|
||||
const indices: number[] = []
|
||||
let offset = 0
|
||||
let orderedMatch = true
|
||||
for (const part of parts) {
|
||||
let idx = merged.indexOf(part, offset)
|
||||
let matchedLen = part.length
|
||||
if (idx === -1) {
|
||||
const stripped = stripTrailingContinuationTokens(part)
|
||||
if (stripped !== part) {
|
||||
idx = merged.indexOf(stripped, offset)
|
||||
if (idx !== -1) matchedLen = stripped.length
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
const segment = merged.slice(offset)
|
||||
const segmentStripped = stripMergeOperatorChars(segment)
|
||||
const partStripped = stripMergeOperatorChars(part)
|
||||
const fuzzyIdx = segmentStripped.indexOf(partStripped)
|
||||
if (fuzzyIdx !== -1) {
|
||||
let strippedPos = 0
|
||||
let originalPos = 0
|
||||
while (strippedPos < fuzzyIdx && originalPos < segment.length) {
|
||||
if (!/[|&?]/.test(segment[originalPos])) strippedPos += 1
|
||||
originalPos += 1
|
||||
}
|
||||
idx = offset + originalPos
|
||||
matchedLen = part.length
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
orderedMatch = false
|
||||
break
|
||||
}
|
||||
indices.push(idx)
|
||||
offset = idx + matchedLen
|
||||
}
|
||||
|
||||
const expanded: string[] = []
|
||||
if (orderedMatch) {
|
||||
for (let i = 0; i < indices.length; i += 1) {
|
||||
const start = indices[i]
|
||||
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
|
||||
const candidate = merged.slice(start, end).trim()
|
||||
if (candidate.length === 0) {
|
||||
orderedMatch = false
|
||||
break
|
||||
}
|
||||
expanded.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedMatch && expanded.length === originalLines.length) {
|
||||
return expanded
|
||||
}
|
||||
|
||||
const semicolonSplit = merged
|
||||
.split(/;\s+/)
|
||||
.map((line, idx, arr) => {
|
||||
if (idx < arr.length - 1 && !line.endsWith(";")) {
|
||||
return `${line};`
|
||||
}
|
||||
return line
|
||||
})
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
if (semicolonSplit.length === originalLines.length) {
|
||||
return semicolonSplit
|
||||
}
|
||||
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
export function restoreIndentForPairedReplacement(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
if (originalLines.length !== replacementLines.length) {
|
||||
return replacementLines
|
||||
}
|
||||
|
||||
return replacementLines.map((line, idx) => {
|
||||
if (line.length === 0) return line
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
const indent = leadingWhitespace(originalLines[idx])
|
||||
if (indent.length === 0) return line
|
||||
return `${indent}${line}`
|
||||
})
|
||||
}
|
||||
|
||||
export function autocorrectReplacementLines(
|
||||
originalLines: string[],
|
||||
replacementLines: string[]
|
||||
): string[] {
|
||||
let next = replacementLines
|
||||
next = maybeExpandSingleLineMerge(originalLines, next)
|
||||
next = restoreOldWrappedLines(originalLines, next)
|
||||
next = restoreIndentForPairedReplacement(originalLines, next)
|
||||
return next
|
||||
}
|
||||
47
src/tools/hashline-edit/edit-deduplication.ts
Normal file
47
src/tools/hashline-edit/edit-deduplication.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { toNewLines } from "./edit-text-normalization"
|
||||
|
||||
function normalizeEditPayload(payload: string | string[]): string {
|
||||
return toNewLines(payload).join("\n")
|
||||
}
|
||||
|
||||
function buildDedupeKey(edit: HashlineEdit): string {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "replace_lines":
|
||||
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_after":
|
||||
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_before":
|
||||
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_between":
|
||||
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
|
||||
case "replace":
|
||||
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
|
||||
case "append":
|
||||
return `append|${normalizeEditPayload(edit.text)}`
|
||||
case "prepend":
|
||||
return `prepend|${normalizeEditPayload(edit.text)}`
|
||||
default:
|
||||
return JSON.stringify(edit)
|
||||
}
|
||||
}
|
||||
|
||||
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
||||
const seen = new Set<string>()
|
||||
const deduped: HashlineEdit[] = []
|
||||
let deduplicatedEdits = 0
|
||||
|
||||
for (const edit of edits) {
|
||||
const key = buildDedupeKey(edit)
|
||||
if (seen.has(key)) {
|
||||
deduplicatedEdits += 1
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
deduped.push(edit)
|
||||
}
|
||||
|
||||
return { edits: deduped, deduplicatedEdits }
|
||||
}
|
||||
160
src/tools/hashline-edit/edit-operation-primitives.ts
Normal file
160
src/tools/hashline-edit/edit-operation-primitives.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
|
||||
import {
|
||||
restoreLeadingIndent,
|
||||
stripInsertAnchorEcho,
|
||||
stripInsertBeforeEcho,
|
||||
stripInsertBoundaryEcho,
|
||||
stripRangeBoundaryEcho,
|
||||
toNewLines,
|
||||
} from "./edit-text-normalization"
|
||||
import { parseLineRef, validateLineRef } from "./validation"
|
||||
|
||||
interface EditApplyOptions {
|
||||
skipValidation?: boolean
|
||||
}
|
||||
|
||||
function shouldValidate(options?: EditApplyOptions): boolean {
|
||||
return options?.skipValidation !== true
|
||||
}
|
||||
|
||||
export function applySetLine(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const originalLine = lines[line - 1] ?? ""
|
||||
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
|
||||
const replacement = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(originalLine, entry)
|
||||
})
|
||||
result.splice(line - 1, 1, ...replacement)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplaceLines(
|
||||
lines: string[],
|
||||
startAnchor: string,
|
||||
endAnchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, startAnchor)
|
||||
validateLineRef(lines, endAnchor)
|
||||
}
|
||||
|
||||
const { line: startLine } = parseLineRef(startAnchor)
|
||||
const { line: endLine } = parseLineRef(endAnchor)
|
||||
|
||||
if (startLine > endLine) {
|
||||
throw new Error(
|
||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const originalRange = lines.slice(startLine - 1, endLine)
|
||||
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
||||
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||
const restored = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertAfter(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBefore(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBetween(
|
||||
lines: string[],
|
||||
afterAnchor: string,
|
||||
beforeAnchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, afterAnchor)
|
||||
validateLineRef(lines, beforeAnchor)
|
||||
}
|
||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||
if (beforeLine <= afterLine) {
|
||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||
}
|
||||
result.splice(beforeLine - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("append requires non-empty text")
|
||||
}
|
||||
if (lines.length === 1 && lines[0] === "") {
|
||||
return [...normalized]
|
||||
}
|
||||
return [...lines, ...normalized]
|
||||
}
|
||||
|
||||
export function applyPrepend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("prepend requires non-empty text")
|
||||
}
|
||||
if (lines.length === 1 && lines[0] === "") {
|
||||
return [...normalized]
|
||||
}
|
||||
return [...normalized, ...lines]
|
||||
}
|
||||
|
||||
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(`Text not found: "${oldText}"`)
|
||||
}
|
||||
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
||||
return content.replaceAll(oldText, replacement)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { applyHashlineEdits, applyInsertAfter, applyReplace, applyReplaceLines, applySetLine } from "./edit-operations"
|
||||
import { applyAppend, applyPrepend } from "./edit-operation-primitives"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
@@ -246,6 +247,124 @@ describe("hashline edit operations", () => {
|
||||
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), ["return 3", "return 4"])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["if (x) {", " return 3", "return 4", "}"])
|
||||
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
|
||||
})
|
||||
|
||||
it("collapses wrapped replacement span back to unique original single line", () => {
|
||||
//#given
|
||||
const lines = [
|
||||
"const request = buildRequest({ method: \"GET\", retries: 3 })",
|
||||
"const done = true",
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(
|
||||
lines,
|
||||
anchorFor(lines, 1),
|
||||
anchorFor(lines, 1),
|
||||
["const request = buildRequest({", "method: \"GET\", retries: 3 })"]
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([
|
||||
"const request = buildRequest({ method: \"GET\", retries: 3 })",
|
||||
"const done = true",
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps wrapped replacement when canonical match is not unique in original lines", () => {
|
||||
//#given
|
||||
const lines = ["const query = a + b", "const query = a+b", "const done = true"]
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["const query = a +", "b"])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["const query = a +", "b", "const done = true"])
|
||||
})
|
||||
|
||||
it("keeps wrapped replacement when same canonical candidate appears multiple times", () => {
|
||||
//#given
|
||||
const lines = ["const expression = alpha + beta + gamma", "const done = true"]
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), [
|
||||
"const expression = alpha +",
|
||||
"beta + gamma",
|
||||
"const expression = alpha +",
|
||||
"beta + gamma",
|
||||
])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([
|
||||
"const expression = alpha +",
|
||||
"beta + gamma",
|
||||
"const expression = alpha +",
|
||||
"beta + gamma",
|
||||
"const done = true",
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps wrapped replacement when canonical match is shorter than threshold", () => {
|
||||
//#given
|
||||
const lines = ["a + b", "const done = true"]
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), ["a +", "b"])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["a +", "b", "const done = true"])
|
||||
})
|
||||
|
||||
it("applies append and prepend operations", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2"
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, [
|
||||
{ type: "append", text: ["line 3"] },
|
||||
{ type: "prepend", text: ["line 0"] },
|
||||
])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 0\nline 1\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("appends to empty file without extra blank line", () => {
|
||||
//#given
|
||||
const lines = [""]
|
||||
|
||||
//#when
|
||||
const result = applyAppend(lines, ["line1"])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line1"])
|
||||
})
|
||||
|
||||
it("prepends to empty file without extra blank line", () => {
|
||||
//#given
|
||||
const lines = [""]
|
||||
|
||||
//#when
|
||||
const result = applyPrepend(lines, ["line1"])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line1"])
|
||||
})
|
||||
|
||||
it("autocorrects single-line merged replacement into original line count", () => {
|
||||
//#given
|
||||
const lines = ["const a = 1;", "const b = 2;"]
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(
|
||||
lines,
|
||||
anchorFor(lines, 1),
|
||||
anchorFor(lines, 2),
|
||||
"const a = 10; const b = 20;"
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["const a = 10;", "const b = 20;"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
|
||||
import { dedupeEdits } from "./edit-deduplication"
|
||||
import { collectLineRefs, getEditLineNumber } from "./edit-ordering"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import {
|
||||
restoreLeadingIndent,
|
||||
stripInsertAnchorEcho,
|
||||
stripInsertBeforeEcho,
|
||||
stripInsertBoundaryEcho,
|
||||
stripRangeBoundaryEcho,
|
||||
toNewLines,
|
||||
} from "./edit-text-normalization"
|
||||
applyAppend,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyPrepend,
|
||||
applyReplace,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operation-primitives"
|
||||
import { validateLineRefs } from "./validation"
|
||||
|
||||
export interface HashlineApplyReport {
|
||||
content: string
|
||||
@@ -15,158 +19,6 @@ export interface HashlineApplyReport {
|
||||
deduplicatedEdits: number
|
||||
}
|
||||
|
||||
export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] {
|
||||
validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const replacement = toNewLines(newText).map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[line - 1], entry)
|
||||
})
|
||||
result.splice(line - 1, 1, ...replacement)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplaceLines(
|
||||
lines: string[],
|
||||
startAnchor: string,
|
||||
endAnchor: string,
|
||||
newText: string | string[]
|
||||
): string[] {
|
||||
validateLineRef(lines, startAnchor)
|
||||
validateLineRef(lines, endAnchor)
|
||||
|
||||
const { line: startLine } = parseLineRef(startAnchor)
|
||||
const { line: endLine } = parseLineRef(endAnchor)
|
||||
|
||||
if (startLine > endLine) {
|
||||
throw new Error(
|
||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
||||
const restored = stripped.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertAfter(lines: string[], anchor: string, text: string | string[]): string[] {
|
||||
validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBefore(lines: string[], anchor: string, text: string | string[]): string[] {
|
||||
validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBetween(
|
||||
lines: string[],
|
||||
afterAnchor: string,
|
||||
beforeAnchor: string,
|
||||
text: string | string[]
|
||||
): string[] {
|
||||
validateLineRef(lines, afterAnchor)
|
||||
validateLineRef(lines, beforeAnchor)
|
||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||
if (beforeLine <= afterLine) {
|
||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||
}
|
||||
result.splice(beforeLine - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(`Text not found: "${oldText}"`)
|
||||
}
|
||||
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
||||
return content.replaceAll(oldText, replacement)
|
||||
}
|
||||
|
||||
function getEditLineNumber(edit: HashlineEdit): number {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return parseLineRef(edit.line).line
|
||||
case "replace_lines":
|
||||
return parseLineRef(edit.end_line).line
|
||||
case "insert_after":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_before":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_between":
|
||||
return parseLineRef(edit.before_line).line
|
||||
case "replace":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEditPayload(payload: string | string[]): string {
|
||||
return toNewLines(payload).join("\n")
|
||||
}
|
||||
|
||||
function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
||||
const seen = new Set<string>()
|
||||
const deduped: HashlineEdit[] = []
|
||||
let deduplicatedEdits = 0
|
||||
|
||||
for (const edit of edits) {
|
||||
const key = (() => {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "replace_lines":
|
||||
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_after":
|
||||
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_before":
|
||||
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_between":
|
||||
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
|
||||
case "replace":
|
||||
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
|
||||
}
|
||||
})()
|
||||
|
||||
if (seen.has(key)) {
|
||||
deduplicatedEdits += 1
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
deduped.push(edit)
|
||||
}
|
||||
|
||||
return { edits: deduped, deduplicatedEdits }
|
||||
}
|
||||
|
||||
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
|
||||
if (edits.length === 0) {
|
||||
return {
|
||||
@@ -182,40 +34,23 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
let noopEdits = 0
|
||||
|
||||
let result = content
|
||||
let lines = result.split("\n")
|
||||
let lines = result.length === 0 ? [] : result.split("\n")
|
||||
|
||||
const refs = sortedEdits.flatMap((edit) => {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return [edit.line]
|
||||
case "replace_lines":
|
||||
return [edit.start_line, edit.end_line]
|
||||
case "insert_after":
|
||||
return [edit.line]
|
||||
case "insert_before":
|
||||
return [edit.line]
|
||||
case "insert_between":
|
||||
return [edit.after_line, edit.before_line]
|
||||
case "replace":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
const refs = collectLineRefs(sortedEdits)
|
||||
validateLineRefs(lines, refs)
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
switch (edit.type) {
|
||||
case "set_line": {
|
||||
lines = applySetLine(lines, edit.line, edit.text)
|
||||
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true })
|
||||
break
|
||||
}
|
||||
case "replace_lines": {
|
||||
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text)
|
||||
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
|
||||
break
|
||||
}
|
||||
case "insert_after": {
|
||||
const next = applyInsertAfter(lines, edit.line, edit.text)
|
||||
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -224,7 +59,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
break
|
||||
}
|
||||
case "insert_before": {
|
||||
const next = applyInsertBefore(lines, edit.line, edit.text)
|
||||
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -233,7 +68,25 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
break
|
||||
}
|
||||
case "insert_between": {
|
||||
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text)
|
||||
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "append": {
|
||||
const next = applyAppend(lines, edit.text)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "prepend": {
|
||||
const next = applyPrepend(lines, edit.text)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -243,11 +96,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
}
|
||||
case "replace": {
|
||||
result = lines.join("\n")
|
||||
if (!result.includes(edit.old_text)) {
|
||||
throw new Error(`Text not found: "${edit.old_text}"`)
|
||||
}
|
||||
const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text
|
||||
const replaced = result.replaceAll(edit.old_text, replacement)
|
||||
const replaced = applyReplace(result, edit.old_text, edit.new_text)
|
||||
if (replaced === result) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -269,3 +118,12 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||
return applyHashlineEditsWithReport(content, edits).content
|
||||
}
|
||||
|
||||
export {
|
||||
applySetLine,
|
||||
applyReplaceLines,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyReplace,
|
||||
} from "./edit-operation-primitives"
|
||||
|
||||
48
src/tools/hashline-edit/edit-ordering.ts
Normal file
48
src/tools/hashline-edit/edit-ordering.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { parseLineRef } from "./validation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
export function getEditLineNumber(edit: HashlineEdit): number {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return parseLineRef(edit.line).line
|
||||
case "replace_lines":
|
||||
return parseLineRef(edit.end_line).line
|
||||
case "insert_after":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_before":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_between":
|
||||
return parseLineRef(edit.before_line).line
|
||||
case "append":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
case "prepend":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
case "replace":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
export function collectLineRefs(edits: HashlineEdit[]): string[] {
|
||||
return edits.flatMap((edit) => {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return [edit.line]
|
||||
case "replace_lines":
|
||||
return [edit.start_line, edit.end_line]
|
||||
case "insert_after":
|
||||
return [edit.line]
|
||||
case "insert_before":
|
||||
return [edit.line]
|
||||
case "insert_between":
|
||||
return [edit.after_line, edit.before_line]
|
||||
case "append":
|
||||
case "prepend":
|
||||
case "replace":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
44
src/tools/hashline-edit/file-text-canonicalization.ts
Normal file
44
src/tools/hashline-edit/file-text-canonicalization.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface FileTextEnvelope {
|
||||
content: string
|
||||
hadBom: boolean
|
||||
lineEnding: "\n" | "\r\n"
|
||||
}
|
||||
|
||||
function detectLineEnding(content: string): "\n" | "\r\n" {
|
||||
const crlfIndex = content.indexOf("\r\n")
|
||||
const lfIndex = content.indexOf("\n")
|
||||
if (lfIndex === -1) return "\n"
|
||||
if (crlfIndex === -1) return "\n"
|
||||
return crlfIndex < lfIndex ? "\r\n" : "\n"
|
||||
}
|
||||
|
||||
function stripBom(content: string): { content: string; hadBom: boolean } {
|
||||
if (!content.startsWith("\uFEFF")) {
|
||||
return { content, hadBom: false }
|
||||
}
|
||||
return { content: content.slice(1), hadBom: true }
|
||||
}
|
||||
|
||||
function normalizeToLf(content: string): string {
|
||||
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
|
||||
if (lineEnding === "\n") return content
|
||||
return content.replace(/\n/g, "\r\n")
|
||||
}
|
||||
|
||||
export function canonicalizeFileText(content: string): FileTextEnvelope {
|
||||
const stripped = stripBom(content)
|
||||
return {
|
||||
content: normalizeToLf(stripped.content),
|
||||
hadBom: stripped.hadBom,
|
||||
lineEnding: detectLineEnding(stripped.content),
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
|
||||
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
|
||||
if (!envelope.hadBom) return withLineEnding
|
||||
return `\uFEFF${withLineEnding}`
|
||||
}
|
||||
31
src/tools/hashline-edit/hashline-edit-diff.ts
Normal file
31
src/tools/hashline-edit/hashline-edit-diff.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
|
||||
export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string {
|
||||
const oldLines = oldContent.split("\n")
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
||||
const maxLines = Math.max(oldLines.length, newLines.length)
|
||||
|
||||
for (let i = 0; i < maxLines; i += 1) {
|
||||
const oldLine = oldLines[i] ?? ""
|
||||
const newLine = newLines[i] ?? ""
|
||||
const lineNum = i + 1
|
||||
const hash = computeLineHash(lineNum, newLine)
|
||||
|
||||
if (i >= oldLines.length) {
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
continue
|
||||
}
|
||||
if (i >= newLines.length) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
continue
|
||||
}
|
||||
if (oldLine !== newLine) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
146
src/tools/hashline-edit/hashline-edit-executor.ts
Normal file
146
src/tools/hashline-edit/hashline-edit-executor.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
||||
import { countLineDiffs, generateUnifiedDiff, toHashlineContent } from "./diff-utils"
|
||||
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
||||
import { generateHashlineDiff } from "./hashline-edit-diff"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
filePath: string
|
||||
edits: HashlineEdit[]
|
||||
delete?: boolean
|
||||
rename?: string
|
||||
}
|
||||
|
||||
type ToolContextWithCallID = ToolContext & {
|
||||
callID?: string
|
||||
callId?: string
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
type ToolContextWithMetadata = ToolContextWithCallID & {
|
||||
metadata?: (value: unknown) => void
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
|
||||
if (edits.length === 0) return false
|
||||
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
|
||||
}
|
||||
|
||||
function buildSuccessMeta(
|
||||
effectivePath: string,
|
||||
beforeContent: string,
|
||||
afterContent: string,
|
||||
noopEdits: number,
|
||||
deduplicatedEdits: number
|
||||
) {
|
||||
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
|
||||
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
|
||||
|
||||
return {
|
||||
title: effectivePath,
|
||||
metadata: {
|
||||
filePath: effectivePath,
|
||||
path: effectivePath,
|
||||
file: effectivePath,
|
||||
diff: unifiedDiff,
|
||||
noopEdits,
|
||||
deduplicatedEdits,
|
||||
filediff: {
|
||||
file: effectivePath,
|
||||
path: effectivePath,
|
||||
filePath: effectivePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise<string> {
|
||||
try {
|
||||
const metadataContext = context as ToolContextWithMetadata
|
||||
const filePath = args.filePath
|
||||
const { edits, delete: deleteMode, rename } = args
|
||||
|
||||
if (deleteMode && rename) {
|
||||
return "Error: delete and rename cannot be used together"
|
||||
}
|
||||
if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) {
|
||||
return "Error: edits parameter must be a non-empty array"
|
||||
}
|
||||
if (deleteMode && edits.length > 0) {
|
||||
return "Error: delete mode requires edits to be an empty array"
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const exists = await file.exists()
|
||||
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
||||
return `Error: File not found: ${filePath}`
|
||||
}
|
||||
|
||||
if (deleteMode) {
|
||||
if (!exists) return `Error: File not found: ${filePath}`
|
||||
await Bun.file(filePath).delete()
|
||||
return `Successfully deleted ${filePath}`
|
||||
}
|
||||
|
||||
const rawOldContent = exists ? Buffer.from(await file.arrayBuffer()).toString("utf8") : ""
|
||||
const oldEnvelope = canonicalizeFileText(rawOldContent)
|
||||
|
||||
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
|
||||
const canonicalNewContent = applyResult.content
|
||||
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
|
||||
|
||||
await Bun.write(filePath, writeContent)
|
||||
|
||||
if (rename && rename !== filePath) {
|
||||
await Bun.write(rename, writeContent)
|
||||
await Bun.file(filePath).delete()
|
||||
}
|
||||
|
||||
const effectivePath = rename && rename !== filePath ? rename : filePath
|
||||
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
|
||||
const newHashlined = toHashlineContent(canonicalNewContent)
|
||||
const meta = buildSuccessMeta(
|
||||
effectivePath,
|
||||
oldEnvelope.content,
|
||||
canonicalNewContent,
|
||||
applyResult.noopEdits,
|
||||
applyResult.deduplicatedEdits
|
||||
)
|
||||
|
||||
if (typeof metadataContext.metadata === "function") {
|
||||
metadataContext.metadata(meta)
|
||||
}
|
||||
|
||||
const callID = resolveToolCallID(metadataContext)
|
||||
if (callID) {
|
||||
storeToolMetadata(context.sessionID, callID, meta)
|
||||
}
|
||||
|
||||
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
|
||||
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
|
||||
|
||||
${diff}
|
||||
|
||||
Updated file (LINE#ID:content):
|
||||
${newHashlined}`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.toLowerCase().includes("hash")) {
|
||||
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
|
||||
}
|
||||
return `Error: ${message}`
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,17 @@ export {
|
||||
} from "./hash-computation"
|
||||
export { parseLineRef, validateLineRef } from "./validation"
|
||||
export type { LineRef } from "./validation"
|
||||
export type { SetLine, ReplaceLines, InsertAfter, InsertBefore, InsertBetween, Replace, HashlineEdit } from "./types"
|
||||
export type {
|
||||
SetLine,
|
||||
ReplaceLines,
|
||||
InsertAfter,
|
||||
InsertBefore,
|
||||
InsertBetween,
|
||||
Replace,
|
||||
Append,
|
||||
Prepend,
|
||||
HashlineEdit,
|
||||
} from "./types"
|
||||
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
|
||||
export {
|
||||
applyHashlineEdits,
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
export const HASHLINE_EDIT_DESCRIPTION = `Edit files using LINE#ID format for precise, safe modifications.
|
||||
|
||||
WORKFLOW:
|
||||
1. Read the file and copy exact LINE#ID anchors.
|
||||
2. Submit one edit call with all related operations for that file.
|
||||
3. If more edits are needed after success, use the latest anchors from read/edit output.
|
||||
4. Use anchors as "LINE#ID" only (never include trailing ":content").
|
||||
1. Read target file/range and copy exact LINE#ID tags.
|
||||
2. Pick the smallest operation per logical mutation site.
|
||||
3. Submit one edit call per file with all related operations.
|
||||
4. If same file needs another call, re-read first.
|
||||
5. Use anchors as "LINE#ID" only (never include trailing ":content").
|
||||
|
||||
VALIDATION:
|
||||
- Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||
- Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace
|
||||
- text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||
Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace, append, prepend
|
||||
text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
||||
|
||||
LINE#ID FORMAT (CRITICAL - READ CAREFULLY):
|
||||
Each line reference must be in "LINE#ID" format where:
|
||||
- LINE: 1-based line number
|
||||
- ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
|
||||
OPERATION TYPES:
|
||||
1. set_line
|
||||
2. replace_lines
|
||||
3. insert_after
|
||||
4. insert_before
|
||||
5. insert_between
|
||||
6. replace
|
||||
LINE#ID FORMAT (CRITICAL):
|
||||
Each line reference must be in "LINE#ID" format where:
|
||||
LINE: 1-based line number
|
||||
ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
|
||||
FILE MODES:
|
||||
- delete=true deletes file and requires edits=[] with no rename
|
||||
- rename moves final content to a new path and removes old path
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
|
||||
CONTENT FORMAT:
|
||||
- text/new_text can be a string (single line) or string[] (multi-line, preferred).
|
||||
- If you pass a multi-line string, it is split by real newline characters.
|
||||
- Literal "\\n" is preserved as text.`
|
||||
text/new_text can be a string (single line) or string[] (multi-line, preferred).
|
||||
If you pass a multi-line string, it is split by real newline characters.
|
||||
Literal "\\n" is preserved as text.
|
||||
|
||||
FILE CREATION:
|
||||
append: adds content at EOF. If file does not exist, creates it.
|
||||
prepend: adds content at BOF. If file does not exist, creates it.
|
||||
CRITICAL: append/prepend are the only operations that work without an existing file.
|
||||
|
||||
OPERATION CHOICE:
|
||||
One line wrong -> set_line
|
||||
Adjacent block rewrite or swap/move -> replace_lines (prefer one range op over many single-line ops)
|
||||
Both boundaries known -> insert_between (ALWAYS prefer over insert_after/insert_before)
|
||||
One boundary known -> insert_after or insert_before
|
||||
New file or EOF/BOF addition -> append or prepend
|
||||
No LINE#ID available -> replace (last resort)
|
||||
|
||||
RULES (CRITICAL):
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
|
||||
3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
|
||||
4. No no-ops: replacement content must differ from current content.
|
||||
5. Touch only requested code: avoid incidental edits.
|
||||
6. Use exact current tokens: NEVER rewrite approximately.
|
||||
7. For swaps/moves: prefer one range operation over multiple single-line operations.
|
||||
8. Output tool calls only; no prose or commentary between them.
|
||||
|
||||
TAG CHOICE (ALWAYS):
|
||||
- Copy tags exactly from read output or >>> mismatch output.
|
||||
- NEVER guess tags.
|
||||
- Prefer insert_between over insert_after/insert_before when both boundaries are known.
|
||||
- Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
- Anti-pattern warning: blank/whitespace anchors are fragile.
|
||||
- Re-read after each successful edit call before issuing another on the same file.
|
||||
|
||||
AUTOCORRECT (built-in - you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
Indentation is auto-restored from original lines.
|
||||
BOM and CRLF line endings are preserved automatically.
|
||||
Hashline prefixes and diff markers in text are auto-stripped.
|
||||
|
||||
RECOVERY (when >>> mismatch error appears):
|
||||
Copy the updated LINE#ID tags shown in the error output directly.
|
||||
Re-read only if the needed tags are missing from the error snippet.
|
||||
ALWAYS batch all edits for one file in a single call.`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { createHashlineEditTool } from "./tools"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import { canonicalizeFileText } from "./file-text-canonicalization"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
@@ -216,4 +217,72 @@ describe("createHashlineEditTool", () => {
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
expect(result).toContain("Successfully deleted")
|
||||
})
|
||||
|
||||
it("creates missing file with append and prepend", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "created.txt")
|
||||
|
||||
//#when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [
|
||||
{ type: "append", text: ["line2"] },
|
||||
{ type: "prepend", text: ["line1"] },
|
||||
],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(fs.existsSync(filePath)).toBe(true)
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2")
|
||||
expect(result).toContain("Successfully applied 2 edit(s)")
|
||||
})
|
||||
|
||||
it("preserves BOM and CRLF through hashline_edit", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "crlf-bom.txt")
|
||||
const bomCrLf = "\uFEFFline1\r\nline2\r\n"
|
||||
fs.writeFileSync(filePath, bomCrLf)
|
||||
const line2Hash = computeLineHash(2, "line2")
|
||||
|
||||
//#when
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
//#then
|
||||
const bytes = fs.readFileSync(filePath)
|
||||
expect(bytes[0]).toBe(0xef)
|
||||
expect(bytes[1]).toBe(0xbb)
|
||||
expect(bytes[2]).toBe(0xbf)
|
||||
expect(bytes.toString("utf-8")).toBe("\uFEFFline1\r\nline2-updated\r\n")
|
||||
})
|
||||
|
||||
it("detects LF as line ending when LF appears before CRLF", () => {
|
||||
//#given
|
||||
const content = "line1\nline2\r\nline3"
|
||||
|
||||
//#when
|
||||
const envelope = canonicalizeFileText(content)
|
||||
|
||||
//#then
|
||||
expect(envelope.lineEnding).toBe("\n")
|
||||
})
|
||||
|
||||
it("detects CRLF as line ending when CRLF appears before LF", () => {
|
||||
//#given
|
||||
const content = "line1\r\nline2\nline3"
|
||||
|
||||
//#when
|
||||
const envelope = canonicalizeFileText(content)
|
||||
|
||||
//#then
|
||||
expect(envelope.lineEnding).toBe("\r\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
|
||||
import { executeHashlineEditTool } from "./hashline-edit-executor"
|
||||
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
@@ -13,49 +10,6 @@ interface HashlineEditArgs {
|
||||
rename?: string
|
||||
}
|
||||
|
||||
type ToolContextWithCallID = ToolContext & {
|
||||
callID?: string
|
||||
callId?: string
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
type ToolContextWithMetadata = ToolContextWithCallID & {
|
||||
metadata?: (value: unknown) => void
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
||||
return undefined
|
||||
}
|
||||
|
||||
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
|
||||
const oldLines = oldContent.split("\n")
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
||||
|
||||
const maxLines = Math.max(oldLines.length, newLines.length)
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const oldLine = oldLines[i] ?? ""
|
||||
const newLine = newLines[i] ?? ""
|
||||
const lineNum = i + 1
|
||||
const hash = computeLineHash(lineNum, newLine)
|
||||
|
||||
if (i >= oldLines.length) {
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
} else if (i >= newLines.length) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
} else if (oldLine !== newLine) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
export function createHashlineEditTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: HASHLINE_EDIT_DESCRIPTION,
|
||||
@@ -110,101 +64,22 @@ export function createHashlineEditTool(): ToolDefinition {
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||
.describe("Replacement text (string or string[] for multiline)"),
|
||||
}),
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("append"),
|
||||
text: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||
.describe("Content to append at EOF; also creates missing file"),
|
||||
}),
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("prepend"),
|
||||
text: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||
.describe("Content to prepend at BOF; also creates missing file"),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.describe("Array of edit operations to apply (empty when delete=true)"),
|
||||
},
|
||||
execute: async (args: HashlineEditArgs, context: ToolContext) => {
|
||||
try {
|
||||
const metadataContext = context as ToolContextWithMetadata
|
||||
const filePath = args.filePath
|
||||
const { edits, delete: deleteMode, rename } = args
|
||||
|
||||
if (deleteMode && rename) {
|
||||
return "Error: delete and rename cannot be used together"
|
||||
}
|
||||
|
||||
if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) {
|
||||
return "Error: edits parameter must be a non-empty array"
|
||||
}
|
||||
if (deleteMode && edits.length > 0) {
|
||||
return "Error: delete mode requires edits to be an empty array"
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const exists = await file.exists()
|
||||
if (!exists) {
|
||||
return `Error: File not found: ${filePath}`
|
||||
}
|
||||
|
||||
if (deleteMode) {
|
||||
await Bun.file(filePath).delete()
|
||||
return `Successfully deleted ${filePath}`
|
||||
}
|
||||
|
||||
const oldContent = await file.text()
|
||||
const applyResult = applyHashlineEditsWithReport(oldContent, edits)
|
||||
const newContent = applyResult.content
|
||||
|
||||
await Bun.write(filePath, newContent)
|
||||
|
||||
if (rename && rename !== filePath) {
|
||||
await Bun.write(rename, newContent)
|
||||
await Bun.file(filePath).delete()
|
||||
}
|
||||
|
||||
const effectivePath = rename && rename !== filePath ? rename : filePath
|
||||
|
||||
const diff = generateDiff(oldContent, newContent, effectivePath)
|
||||
const newHashlined = toHashlineContent(newContent)
|
||||
|
||||
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, effectivePath)
|
||||
const { additions, deletions } = countLineDiffs(oldContent, newContent)
|
||||
|
||||
const meta = {
|
||||
title: effectivePath,
|
||||
metadata: {
|
||||
filePath: effectivePath,
|
||||
path: effectivePath,
|
||||
file: effectivePath,
|
||||
diff: unifiedDiff,
|
||||
noopEdits: applyResult.noopEdits,
|
||||
deduplicatedEdits: applyResult.deduplicatedEdits,
|
||||
filediff: {
|
||||
file: effectivePath,
|
||||
path: effectivePath,
|
||||
filePath: effectivePath,
|
||||
before: oldContent,
|
||||
after: newContent,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof metadataContext.metadata === "function") {
|
||||
metadataContext.metadata(meta)
|
||||
}
|
||||
|
||||
const callID = resolveToolCallID(metadataContext)
|
||||
if (callID) {
|
||||
storeToolMetadata(context.sessionID, callID, meta)
|
||||
}
|
||||
|
||||
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
|
||||
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
|
||||
|
||||
${diff}
|
||||
|
||||
Updated file (LINE#ID:content):
|
||||
${newHashlined}`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.toLowerCase().includes("hash")) {
|
||||
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
|
||||
}
|
||||
return `Error: ${message}`
|
||||
}
|
||||
},
|
||||
execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,4 +36,22 @@ export interface Replace {
|
||||
new_text: string | string[]
|
||||
}
|
||||
|
||||
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace
|
||||
export interface Append {
|
||||
type: "append"
|
||||
text: string | string[]
|
||||
}
|
||||
|
||||
export interface Prepend {
|
||||
type: "prepend"
|
||||
text: string | string[]
|
||||
}
|
||||
|
||||
export type HashlineEdit =
|
||||
| SetLine
|
||||
| ReplaceLines
|
||||
| InsertAfter
|
||||
| InsertBefore
|
||||
| InsertBetween
|
||||
| Replace
|
||||
| Append
|
||||
| Prepend
|
||||
|
||||
Reference in New Issue
Block a user