Skip to content

feat(blame): add coco blame --explain command - #2012

Open
gfargo-horizon-agent[bot] wants to merge 3 commits into
mainfrom
agent/coco-1604-coco-1950-feat-19-feat-blame-coco-blame-
Open

feat(blame): add coco blame --explain command#2012
gfargo-horizon-agent[bot] wants to merge 3 commits into
mainfrom
agent/coco-1604-coco-1950-feat-19-feat-blame-coco-blame-

Conversation

@gfargo-horizon-agent

@gfargo-horizon-agent gfargo-horizon-agent Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What

Adds a non-interactive coco blame <file> [--lines a:b] [--explain] [--json] command. Plain mode renders the existing blameData.ts gutter output; --explain resolves each distinct introducing commit and asks an LLM for a per-range intent summary.

Why

Plane: OSS-1604

Blame is currently TUI-only and purely mechanical (who/when, never why). coco already has every input needed (blameData.ts, logData.ts) to answer the most common code-archaeology question: "who wrote this and what were they thinking."

How

  • New src/commands/blame/{config,index,handler,prompt,render}.ts, mirroring the log command's structure.
  • Non-explain path: getBlame() → optional --lines a:b filter → table/JSON render.
  • --explain path: groups blame lines by introducing commit (dedup — 2 lines from the same sha cost one getCommitDetail fetch), resolves commit details, and issues a single batched LLM call covering all distinct commits (not one call per sha).
  • Cost guardrails: refuses un-narrowed --explain runs over 400 lines (points the user at --lines), and caps at 25 distinct commits per call with a truncation notice.
  • --json emits blame lines (+ explanations in explain mode) via emitJson; only the --explain branch touches config/LLM plumbing, so plain coco blame never requires an API key.
  • Added a blameExplain DynamicModelTask entry across all six provider default tables (OPENAI/ANTHROPIC/GEMINI/MISTRAL/BEDROCK/OLLAMA) — this is compiler-enforced by Record<DynamicModelTask, LLMModel>, and is what caused the prior CI failure on this item.
  • Registered in src/index.ts (command, fish completion list, export block) and bin/smokeCli.ts (blame --help + blame README.md against the smoke repo).

Scoped out (per the implementation plan): the in-TUI E binding on the blame surface and PR-lookup-for-explain are deferred to a follow-up — wiring an async LLM action into the TUI runtime is materially riskier than the CLI command and isn't needed for this to land.

Testing

  • build passes
  • tests pass (npx jest src/commands/blame src/lib/langchain/utils/dynamicModels green; full suite green except 4 pre-existing tree-sitter WASM failures reproduced identically on a clean main checkout — unrelated to this change)
  • lint clean (0 errors; pre-existing React-hooks warnings only)
  • Manually verified blame --help, blame README.md --lines 1:5, blame README.md --lines 1:3 --json, and blame README.md --explain (reaches the LLM call and fails gracefully on the sandbox's placeholder API key — same behavior as review/recap/changelog in this environment)
  • CI: pending

🤖 Generated by the harbor agent loop. Reviewed by a human before merge.

Closes #1950

Registers blame as a top-level command (blameData.ts was TUI-only). Plain
mode renders a blame gutter; --explain resolves the distinct introducing
commits via getCommitDetail, batches them into a single LLM call (never
one-per-sha), and prints why each range was likely written. Caps lines/shas
to keep --explain cost-bounded, and adds the blameExplain dynamic-model
task across all six provider tables.

@gfargo-horizon-agent gfargo-horizon-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 Agent review (sonnet→opus) — LGTM

REVIEW: LGTM
RESOLVES: full

The PR implements coco blame <file> [--lines] [--explain] [--json] per the plan: mirrors the log/recap command layout, batches the LLM call by distinct introducing commit with sha/line/token caps, fills blameExplain across the DynamicModelTask type, all six provider tables, the runtime task list, and both schemas, and registers the command everywhere (index, fish completions, export, smoke test) with render/parse unit tests. Deferred items (TUI E binding, PR lookup) match the plan's explicit risk notes and are correctly out of scope; error/no-key paths use commandExit/`handleMissingA

1 nit — 1 inline on the diff

Comment thread src/commands/blame/handler.ts Outdated
handleMissingApiKey(logger, config, { command: 'blame' })
}

if (!range && lines.length > MAX_EXPLAIN_LINES) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Line-count cap skipped when --lines is set

The 400-line pre-check only fires if (!range && ...), so an explicit --lines 1:100000 bypasses the friendly early rejection. Cost stays bounded downstream by the 25-distinct-commit cap plus enforcePromptBudget, so this is a wording inconsistency with the acceptance criterion ('refuses / truncates when the range exceeds a sane sha/line ceiling') rather than a real cost blowup.

Previously the 400-line pre-check only fired when no --lines range was
given, so an explicit large range (e.g. --lines 1:100000) bypassed the
friendly early rejection and fell through to the LLM call.
The test spawns a subprocess that transpiles and imports the module via
tsx, which exceeds Jest's default 5s timeout under macOS CI's heavier
resource contention. Follows the same fix already applied in
src/commands/hooks/handler.test.ts for subprocess-spawning tests.

@gfargo-horizon-agent gfargo-horizon-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 Agent review (sonnet→opus) — CONCERNS

REVIEW: CONCERNS
RESOLVES: partial

The CLI command is fully and cleanly delivered — mechanical blame rendering, a single batched --explain LLM call grouped by introducing commit, unconditional cost caps, a blameExplain DynamicModelTask registered across every provider, and no API-key requirement outside --explain. The sketch's second half — an in-TUI E explain binding on the existing blame surface — is not delivered, so the work item is only partially resolved.

1 concern · 1 nit — 2 inline on the diff

@@ -0,0 +1,250 @@
import { z } from 'zod'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ In-TUI E explain binding not delivered

The work item explicitly asks to 'Add an E binding to the blame surface for the same in-TUI', alongside the CLI command. This PR touches no workstation files and the existing blame surface (src/workstation/surfaces/blame/index.ts) has no explain binding or explain code path. This should be explicitly called out as deferred/out-of-scope on the PR, or added before merge if it was intended to land with this item.


const execFileAsync = promisify(execFile)

// Each test spawns a subprocess that transpiles and imports the validator

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Unrelated timeout fix bundled into blame PR

Raising the jest timeout for the commitlintValidator subprocess test is unrelated to coco blame and is scope creep per repo convention ('keep changes scoped to the work item'). It is harmless and a reasonable flakiness fix, but ideally belongs in its own commit/PR.

@gfargo-horizon-agent gfargo-horizon-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 Agent review (sonnet→opus) — CONCERNS

REVIEW: CONCERNS
RESOLVES: full

Both halves of OSS-1604 now land in the worktree: the CLI --explain and the deferred in-TUI E binding, cleanly factored through a shared explainBlameGroups core with reused cost caps, cache, and abort/ownership guard. Remaining concerns are an ungraceful CLI crash when an introducing commit can't be resolved, no direct tests for the extracted orchestration core, and a 400-line cap that makes E unusable (with a CLI-only remediation message) on large files in the TUI.

  • ⚠️ getCommitDetail failure crashes CLI --explainsrc/commands/blame/explain.ts:108 — The Promise.all over getCommitDetail sits outside the try/catch that wraps executeChain, and the CLI caller (handler.ts:74) does not wrap explainBlameGroups. If any introducing commit can't be resolved (shallow-clone boundary, rewritten/rebased history, submodule edge), the whole --explain invocation throws with a stack trace instead of degrading like an LLM failure does. Moving the detail-resolution into the existing try/catch fixes both the CLI and TUI callers at once (the TUI hook currently masks this only via its own outer catch).
  • ⚠️ No tests for extracted explain orchestration coresrc/commands/blame/explain.ts:61explainBlameGroups is now the shared core for both the CLI and TUI, yet it has zero direct test coverage: the 400-line/25-commit caps, UNCOMMITTED_SHA filtering, truncation accounting, batched-call construction, and the partial-failure fallback text are all unverified. Only render.ts (pure formatting) has a test file, while sibling LLM-backed commands (changelog, review) ship handler/orchestration tests. The extraction into a pure, data-returning function makes this trivially testable, so the gap is now easy to close.
  • ⚠️ In-TUI E unusable on files over 400 linessrc/workstation/runtime/hooks/useBlameExplainActions.ts:81 — The hook always passes the full hydrated blameLines (entire file) to the explain core, but explainBlameGroups rejects any range over MAX_EXPLAIN_LINES (400). For any file longer than 400 lines, E therefore always fails, and the surfaced error tells the user to "Narrow the range with --lines a:b" — a CLI flag the TUI has no equivalent for. The plan anticipated using the selected line's range; scoping the explain window to the visible/selected range (or a bounded window around the cursor) would make the feature usable on large files instead of permanently erroring.
  • 🧹 Unrelated CI-stability fix bundled insrc/lib/utils/commitlintValidator.test.ts:11 — The jest.setTimeout(15000) change fixes a flaky subprocess test unrelated to blame. It's a safe one-liner, but AGENTS.md asks to keep changes scoped to the work item; ideally its own commit/PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEAT-19: feat(blame): coco blame --explain

0 participants