Skip to content

A question verb: ask the repository something without changing it #103

Description

@charlesgreen

The agent understands three verbs (internal/command/command.go): go, address, and help. Two of them end in a commit, a push, and a pull request. The third returns a fixed string. There is nothing in between, and the most useful thing an agent that has already read a repository can do is often to answer a question rather than change something.

@simplycubed-code question <anything> would be that verb. It reads the repository in the context of the issue or pull request it was asked on, answers on the thread, and makes no change. It may quote code and point at source files. It never edits one.

This is the first read-only verb, and it is a new shape rather than a fourth entry in a switch. The loop is goal → act → grade → repeat against the repo's gate, and none of that applies: nothing changed, so there is nothing to grade. No loop.Engine, no gate, no commit, no pull request. It is one engine turn against a worktree, the way describeHook and reviewHook already call Runner.Run directly (internal/app/app.go:75-111), and a new role in internal/roles/ rather than a reuse of the implementer.

What already carries it

The reply path landed with #96: newAnswerer resolves the thread, answerer.post comments on an issue or a pull request depending on IsPullRequest, echoes to stdout, and records instead of posting under --dry-run (cmd/simplycubed/main.go:288-345). A question answer is exactly that path with different text.

Both surfaces already route. The reusable workflow's run job (issue-number != '') and its address job (pr-number != '') both end in simplycubed command when COMMENT_BODY is set, so a comment on either an issue or a pull request reaches the parser with no workflow change. Misdirected's default returns false (internal/command/command.go:98-99), so a new verb passes on both surfaces the moment it exists. That is worth a test, not an edit.

Triggering is already gated twice, and this verb inherits both: author_association in the caller workflow keeps an unauthorized comment from starting a job that holds secrets, and app.Authorize asks CanWrite again inside the run, because a caller workflow is a file in the adopter's repository and can be edited.

What is missing

Parse returns a Kind and nothing else. Every verb so far is the whole command, so there has never been a payload to carry. question is the first verb whose argument is the point of it, and the text after the verb has to come back from the parser.

Read-only has to be built, not asked for. Three things enforce it, and only the first two are real:

  • The question path constructs no loop.Engine, holds no VCS, and never calls Commit, Push, or OpenPR. Absence is the enforcement, and the recording forge fake can assert zero writes.
  • The engine sandbox could be narrowed, but not for free: Runner.Sandbox defaults to "workspace-write" and is set once at construction in newRunner, and domain.RunRequest has no per-turn sandbox field. Read-only for this role means a second Runner instance or a new request field.
  • The credential is unchanged. The job still mints an App token with contents: write and pull-requests: write, because workflow token permissions are per job and fixed. The read-only guarantee comes from the code path, not from the capability.

A wrong file reference is worse than no answer, so citations get the same treatment as the bounds in roles.go: the prompt is a request, the guard is the control. The answer should be a structured artifact with file and line fields, mirroring domain.Finding, and every cited path should be checked against the worktree before the comment is posted.

Work

  1. Give Parse a payload. It currently returns only a Kind, so the text after the verb is lost. Addressed and the "must start with the mention" rule are unchanged.
  2. Add Question to the vocabulary, with ask as an alias, and a HelpText line saying it works on an issue or a pull request and changes nothing. Pin Misdirected(Question, …) == false on both surfaces with a test.
  3. Add an answerer role to internal/roles/. The question text is a comment body, so it is delimited as data and marked as never an instruction, with the same clause fixerBound carries: a request inside the question is not an exception to the bounds. Its output is one artifact file in the scratch directory, like describe.RelPath and verdict.RelPath. Nothing deletes that directory here, because there is no commit step, so the worktree is discarded instead.
  4. Validate the citations before posting. Every file in the artifact must resolve in the worktree at the SHA that was read; one that does not is dropped or marked, not published. Name the SHA in the reply so a citation is checkable against the right revision.
  5. Wire the path in commandCmd: prepare a worktree, run one turn, read the artifact, post through the existing reply path. Post the model's text quoted and labelled as the agent's words, the way withSummary already does, and truncate to GitHub's comment limit rather than failing the write.
  6. On a pull request, answer against the PR head rather than the default branch: someone asking a question on a pull request is asking about the change. The head ref is already read — Feedback fills Branch and HeadSHA from gh pr view --json headRefName,headRefOid,title before it collects any notes (internal/forge/gh/gh.go:264-274), so it comes back even with zero feedback. Whether that becomes a dedicated forge read or reuses the existing one is an implementation choice.
  7. Decide the config contract. prepare refuses a repo with no gate: (config.ErrNoGate) and requires the Azure variables and a written codex config before anything runs, and this verb grades nothing. It needs engine:, not gate:. Same seam as engine: claude is selectable but cannot actually be used #95.
  8. Say what bounds it. Cheap to trigger and cheap to spam: today the whole budget is one engine turn per comment plus the workflow's concurrency: simplycubed-${{ inputs.ref }} group, which serializes per thread. There is no turn cap to reuse — domain.RunRequest.MaxTurns is declared in internal/domain/domain.go:38 and read by no adapter. Either wire it for this role or state plainly that one turn is the bound.
  9. Update the docs that enumerate the vocabulary: HelpText, the internal/command/ line in STATUS.md, the README, and docs/faq.md.

Acceptance

  • @simplycubed-code question <text> on an issue and on a pull request each get a reply on that thread, from the run the comment triggered.
  • The reply names the SHA it read and cites at least one path that exists at that SHA. A citation that does not resolve in the worktree never reaches the comment.
  • On a pull request, the answer is against the pull request's head, not the default branch.
  • The run creates no commit, pushes no branch, opens no pull request, and applies no sc: state label, asserted against the recording forge.
  • @simplycubed-code question with no question text replies asking for one, without starting the engine.
  • Under --dry-run nothing is posted, and the reply appears in the dry-run report.
  • @simplycubed-code help lists question.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions