Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 96 additions & 19 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Dockerfile.harness
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ RUN git config --system --add safe.directory '*' \
&& git config --system core.crossFS true

COPY --chown=node:node server.js pi-guards.ts pi-mcp.ts op-mcp-client.js gh-mcp-client.js pi-settings.json pi-models.json /app/
# The role files: server.js checks each request's grant against its role.
COPY --chown=node:node lib/opilot/prompts/*.yml /app/lib/opilot/prompts/

USER node

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Use OPilot to remove that friction from your development workflow. It can automa

### General audience

* **Refine work packages**: Discuss work packages in the chat via free-form chatting or preset commands like `@OPilot grill`.
* **Refine work packages**: Discuss work packages in the chat via free-form chatting or preset commands like `@OPilot grill`. `@OPilot health` checks the description against the comments, pictures, related work packages, status, linked PRs and commits, and lists what it could not check.
* **Draw diagrams and write reports**: Ask for a diagram or a long report in the chat. OPilot publishes it as a secret gist and links it from the comment — a diagram is a mermaid fence, which the gist renders as a picture. Needs `OPILOT_ALLOWED_OP_USER_IDS`.
* **Create work packages**: Turn a suggestion made in a comment into its own work package(s) with `@OPilot create wp <instructions>`. OPilot builds the package content, creates them in the same project, and relates them back.
* **[Enterprise] Run project-wide discovery**: Ask `@OPilot` anything about the reachable projects' data -- it will leverage the instance's MCP server to give you a fresh answer.
Expand Down
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ OPilot's roadmap. See [README.md](README.md) for what the project already does.
* Inspiration: https://andrewpatterson.dev/posts/token-savings-rtk-headroom/

## Feature ideas
* WP health check command
* Replace OpenSpec with a simple list of acceptance criteria
* Matrix/Element integration for a better interface & activity tracking
* Nextcloud integration, so that we can load relevant data during designs
Expand Down
2 changes: 2 additions & 0 deletions bin/opilot
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ require "opilot/gh_pull"
require "opilot/gh_agent"
require "opilot/combined_agent"
require "opilot/fix_runner"
require "opilot/health_check"
require "opilot/health_runner"
require "opilot/pr_runner"
require "opilot/chat_runner"
require "opilot/usage_runner"
Expand Down
44 changes: 28 additions & 16 deletions lib/opilot/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def handle(intent)
when :chat then handle_chat(intent)
when :ship then handle_ship(intent)
when :create_wp then handle_create_wp(intent)
when :health then handle_health(intent)
end
end

Expand Down Expand Up @@ -123,20 +124,33 @@ def handle_chat(intent)
# Pass the plan's path, not its text: a resumed session already holds the
# plan, so re-embedding it every turn just burns tokens.
plan_ref = st.plan_file.exist? ? container_path(st.plan_file) : "(no plan yet)"
prompt = Prompts.chat(item_id: st.item_id, subject: st.subject,
prompt = Prompts::Advisor.chat(item_id: st.item_id, subject: st.subject,
item: container_path(st.item_file),
plan: plan_ref, message: intent.text.to_s,
related: related_ref(st), can_create_wp: create_wp_enabled?,
can_make_artifact: artifacts_enabled?, max_artifacts: MAX_ARTIFACTS,
op_mcp: @ctx.op_mcp?)
reply = @harness.run(prompt, tools: read_tools, session_file: st.session_file)
reply = llm(:advisor, prompt, session_file: st.session_file)
# Only when artifacts are on: with the instructions never given, a BEGIN
# ARTIFACT line is text the writer invented or quoted, and stripping it
# would delete content from someone's reply.
reply = publish_artifacts(st, intent, reply) if artifacts_enabled?
post_note(st.item_id, addressed(reply.strip)) unless reply.strip.empty?
end

# Answers its own failure, like create wp: the reader waits for a report.
def handle_health(intent)
check = HealthCheck.new(@ctx, pull: @pull, harness: @harness, api: @api)
report = begin
check.run(intent.item_id, focus: intent.text.to_s, internal: intent.internal != false) ||
"The health check could not read this work package."
rescue Harness::Error => e
log_script "Health check failed on #{wp_label(intent.item_id)}: #{e.message}"
"The health check did not finish: the model run failed. Ask again with `@opilot health`."
end
post_note(intent.item_id, addressed(report))
end

# Take the artifacts out of a chat answer, mirror them, publish them as one
# gist, and return the comment to post — the answer without the blocks, plus a
# line naming what was published.
Expand Down Expand Up @@ -284,7 +298,7 @@ def artifacts_enabled?
# enforced HERE, because a prompt limit drifts and a work package can never
# be deleted: "create one for every suggestion in this thread" must not be
# able to mint twenty rows nobody can remove. Five also sits well inside one
# output budget — see Prompts.create_wp on why a cut-off answer is the
# output budget — see Prompts::WpWriter.create_wp on why a cut-off answer is the
# failure mode to fear.
MAX_CREATE_WP = 5

Expand Down Expand Up @@ -373,13 +387,13 @@ def project_type_names(project_id)
# is a lost request, not a duplicate work package.
def write_work_packages(st, request, project_name, types, related, retry_bad: true, format_note: nil)
log_script "Writer: drafting work packages from #{wp_label(st.item_id)} — #{request}"
prompt = Prompts.create_wp(item_id: st.item_id, subject: st.subject,
prompt = Prompts::WpWriter.create_wp(item_id: st.item_id, subject: st.subject,
item: container_path(st.item_file), request: request,
project: project_name, types: Helpers.types_for_prompt(types),
max: MAX_CREATE_WP, related: related, format_note: format_note)
reply = @harness.run(prompt, tools: Harness::TOOLS_READ, session_file: st.session_file).to_s
reply = llm(:wp_writer, prompt, session_file: st.session_file).to_s
# Only what follows the last `ANSWER:` marker; the writer's own deliberation
# is scratch (Prompts.create_wp). Text with no marker is read whole, so an
# is scratch (Prompts::WpWriter.create_wp). Text with no marker is read whole, so an
# answer that skips it still works.
answer = Helpers.after_marker(reply, "ANSWER")

Expand Down Expand Up @@ -490,7 +504,7 @@ def payloads_accepted?(st, payloads, types)
# customer?"), a work package can never be deleted, and a guess would be
# permanent. So the fields are named back to the reader, who can create it in
# OpenProject or NAME A DIFFERENT TYPE — required-ness is per type, and their
# answer lands in this thread, which the next draft reads (Prompts.create_wp's
# answer lands in this thread, which the next draft reads (Prompts::WpWriter.create_wp's
# TYPE line). Choosing another type here instead would be opilot re-classifying
# somebody's work to get past a validation, on a work package nobody can delete.
#
Expand Down Expand Up @@ -740,7 +754,7 @@ def already_created_note(records)
#
# The shape is always STATED, never implied: whether an offshoot is a child of
# this work package or a peer beside it is the writer's per-block decision
# (Prompts.create_wp's LINK line), so the reader cannot work it out from the
# (Prompts::WpWriter.create_wp's LINK line), so the reader cannot work it out from the
# count and must be told.
def single_notes(record)
notes = +""
Expand Down Expand Up @@ -801,7 +815,7 @@ def substituted_type(record)
#
# This is where every `build` trigger lands (alias `fix`). There is
# no separate plan-and-wait command any more: a fix with more than one defensible
# shape stops and offers numbered options (Prompts::OPTIONS_CONTRACT), and a
# shape stops and offers numbered options (Prompts::Planner::OPTIONS_CONTRACT), and a
# fix with one shape is announced (#post_approach_note) and shipped in the
# same call — so a simple ticket still costs exactly one plan call, just
# with a stated approach instead of a silent one. NEEDS_INFO still guards
Expand Down Expand Up @@ -849,7 +863,7 @@ def handle_ship(intent)
#
# `allow_options:` is the caller's judgment that no human has picked an
# approach yet; the writer's judgment is whether the fix really has more than
# one shape (Prompts::OPTIONS_CONTRACT). `:failed` means the call produced
# one shape (Prompts::Planner::OPTIONS_CONTRACT). `:failed` means the call produced
# neither a plan nor a usable options answer, and is handled like any other
# failed run — logged, never commented.
def produce_plan(st, feedback, allow_options: false, retry_bad_options: true)
Expand All @@ -868,21 +882,19 @@ def produce_plan(st, feedback, allow_options: false, retry_bad_options: true)

if feedback && !feedback.empty? && st.plan_file.exist?
log_script "Writer: revising plan for #{wp_label(st.item_id)} from feedback"
prompt = Prompts.replan(repos_summary: @ctx.repos.summary, repos: menu, item: item_c, plan: plan_c,
prompt = Prompts::Planner.replan(repos_summary: @ctx.repos.summary, repos: menu, item: item_c, plan: plan_c,
feedback: feedback, item_id: st.item_id, title: st.subject,
resumed: session_resumable?(st), related: related, op_mcp: @ctx.op_mcp?)
@harness.capture(prompt, tools: read_tools, outfile: st.plan_file,
session_file: st.session_file)
llm(:planner, prompt, outfile: st.plan_file, session_file: st.session_file)
record_chosen_repos(st)
return :ok
end

log_script "Writer: generating plan for #{wp_label(st.item_id)} — #{st.subject}"
prompt = Prompts.plan(repos_summary: @ctx.repos.summary, repos: menu, item: item_c,
prompt = Prompts::Planner.plan(repos_summary: @ctx.repos.summary, repos: menu, item: item_c,
item_id: st.item_id, title: st.subject, hint: feedback.to_s,
related: related, allow_options: allow_options, op_mcp: @ctx.op_mcp?)
@harness.capture(prompt, tools: read_tools, outfile: st.plan_file,
session_file: st.session_file)
llm(:planner, prompt, outfile: st.plan_file, session_file: st.session_file)

if st.plan_file.read.lstrip.start_with?("NEEDS_INFO")
questions = st.plan_file.read.sub(/\A\s*NEEDS_INFO\s*\n?/, "").strip
Expand Down
4 changes: 2 additions & 2 deletions lib/opilot/appsignal_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ def drafted_work_package(dir, number)
# request rather than a duplicate work package.
def write_work_package(number, incident_file, retry_bad: true, format_note: nil)
log_script "Drafting a work package from AppSignal incident ##{number}…"
prompt = Prompts.appsignal_wp(
prompt = Prompts::Triager.appsignal_wp(
incident: container_path(incident_file), number: number, app: @app,
repos: repos_for_prompt(@ctx.repos.all), types: Helpers.types_for_prompt(project_types), format_note: format_note
)
reply = @harness.run(prompt, tools: read_tools, model: Harness::MODEL_HEAVY).to_s
reply = llm(:triager, prompt).to_s
answer = Helpers.after_marker(reply, "ANSWER")

if answer.lstrip.start_with?("NEEDS_INFO")
Expand Down
4 changes: 2 additions & 2 deletions lib/opilot/chat_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ def run(initial_message = nil)
prompt = if oriented
pending
else
Prompts.free_chat(state: @ctx.state_container, wp_root: wp_root, repos: repos,
Prompts::Advisor.free_chat(state: @ctx.state_container, wp_root: wp_root, repos: repos,
message: pending, op_mcp: @ctx.op_mcp?, gh_mcp: @ctx.gh_mcp?)
end
@harness.run(prompt, tools: read_tools, session_file: session_file)
llm(:advisor, prompt, session_file: session_file)
# Set only after the run returns: a failed turn never reached the model,
# so the next one still has to orient it.
oriented = true
Expand Down
1 change: 1 addition & 0 deletions lib/opilot/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def dev(args)
when "build", "fix" then with_ids("dev build", rest) { |ids| FixRunner.new(@ctx).ship_ids(*ids) }
when "commit" then with_ids("dev commit", rest) { |ids| FixRunner.new(@ctx).commit_ids(*ids) }
when "plan" then with_ids("dev plan", rest) { |ids| FixRunner.new(@ctx).plan_ids(*ids) }
when "health" then with_ids("dev health", rest) { |ids| HealthRunner.new(@ctx).run_ids(*ids) }
when "refresh" then refresh(rest)
# Reads .opilot/ only — no config, no network, no log header.
when "status" then @ui.status
Expand Down
7 changes: 7 additions & 0 deletions lib/opilot/clients/openproject.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ def work_package_emoji_reactions(wp_id)
HTTP.get_json("#{@base}/api/v3/work_packages/#{wp_id}/activities_emoji_reactions", token: @token)
end

# PRs the GitHub integration linked to a work package. Needs
# :show_github_content and the project's `github` module, so a 403 or 404
# is a normal answer. `merged` is a boolean; `state` is open/closed/deployed.
def work_package_github_pull_requests(wp_id)
HTTP.get_json("#{@base}/api/v3/work_packages/#{wp_id}/github_pull_requests", token: @token)
end

def me
HTTP.get_json("#{@base}/api/v3/users/me", token: @token)
end
Expand Down
Loading
Loading