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
34 changes: 34 additions & 0 deletions .github/workflows/pr-review-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: PR Review Gate

on:
pull_request:
# "edited" re-runs the gate when the PR description changes, so the review report can be
# added after the PR exists and the check turns green without a new push.
types: [opened, edited, synchronize, reopened]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[other · low]
synchronizeedited トリガーにより連続 push で同じジョブが重複実行されます。このジョブは PR ごとに結果が決まるゲートなので、concurrency によるグループ化と cancel-in-progress: true を設定し、直前の実行をキャンセルして冗長な実行を防ぐことを推奨します。

Suggestion:

Suggested change
types: [opened, edited, synchronize, reopened]
concurrency:
group: pr-review-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
on:
pull_request:
# "edited" re-runs the gate when the PR description changes, so the review report can be
# added after the PR exists and the check turns green without a new push.
types: [opened, edited, synchronize, reopened]

Comment on lines +3 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[performance · medium]
PRイベント(opened/edited/synchronize/reopened)で起動するこのジョブにはconcurrency設定がありません。連続プッシュや説明文編集のたびに同一のゲートチェックが並列実行され、リソースと実行時間を無駄に消費します。concurrencyグループとcancel-in-progress: trueを追加し、同じPRの古い実行をキャンセルすることを推奨します。

Suggestion:

Suggested change
on:
pull_request:
# "edited" re-runs the gate when the PR description changes, so the review report can be
# added after the PR exists and the check turns green without a new push.
types: [opened, edited, synchronize, reopened]
concurrency:
group: pr-review-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
on:
pull_request:
# "edited" re-runs the gate when the PR description changes, so the review report can be
# added after the PR exists and the check turns green without a new push.
types: [opened, edited, synchronize, reopened]


permissions:
contents: read

jobs:
review-gate:
runs-on: ubuntu-latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[other · low]
このジョブには timeout-minutes が設定されていません。grep のみの軽量な処理とはいえ、ランナー上でステップがハングすると無期限に実行され、ランナーリソースを消費し続けます。timeout-minutes: 5 程度を明示することを推奨します。

Suggestion:

Suggested change
runs-on: ubuntu-latest
runs-on: ubuntu-latest
timeout-minutes: 5

Comment on lines +12 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[other · low]
ジョブにtimeout-minutesが設定されていません。万が一run:スクリプトがハングした場合、ジョブが無期限に実行されランナーリソースを占有します。単純なゲートチェックなので、数分のタイムアウトを設定して上限を明示することを推奨します。

Suggestion:

Suggested change
jobs:
review-gate:
runs-on: ubuntu-latest
jobs:
review-gate:
runs-on: ubuntu-latest
timeout-minutes: 5

# Weblate opens automated translation PRs; they cannot run the agent-side review skill.
if: github.event.pull_request.user.login != 'weblate'
Comment on lines +12 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[performance · low]
このワークフローは pull_requestsynchronize(プッシュ毎)と edited(説明文の編集毎)で再実行されます。開発中にコミットを連続でプッシュすると、内容のほぼ同一なジョブが並列にキューされ、ランナーリソースを無駄に消費します。プルリクエスト単位の concurrency グループと cancel-in-progress: true を追加し、最新のジョブだけを実行することを推奨します。

Suggestion:

Suggested change
jobs:
review-gate:
runs-on: ubuntu-latest
# Weblate opens automated translation PRs; they cannot run the agent-side review skill.
if: github.event.pull_request.user.login != 'weblate'
concurrency:
group: pr-review-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review-gate:
runs-on: ubuntu-latest
# Weblate opens automated translation PRs; they cannot run the agent-side review skill.
if: github.event.pull_request.user.login != 'weblate'

steps:
- name: Require an approved pre-PR review
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
if printf '%s' "$PR_BODY" | grep -qi 'pre-pr-review: approved'; then
echo "Approved pre-PR review is recorded in the PR description."
else
echo "::error::This PR has no approved pre-PR review."
echo ""
echo "Every PR must be reviewed by the repo-reviewer subagent before it is opened."
echo "Run the 'pre-pr-review' skill (see .opencode/skills/pre-pr-review/SKILL.md):"
echo " 1. Let the repo-reviewer subagent review the branch diff against origin/main."
echo " 2. Fix any blocking findings and re-review until the verdict is APPROVE."
echo " 3. Paste the reviewer's report into this PR's description inside the marker:"
echo " <!-- pre-pr-review: approved -->"
exit 1
fi
96 changes: 96 additions & 0 deletions .opencode/agents/repo-reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
description: Read-only code reviewer with deep knowledge of the AndCode Android repository. Use via the pre-pr-review skill before opening a pull request.
mode: subagent
temperature: 0.1
color: accent
permission:
edit: deny
webfetch: deny
task: deny
bash:
"*": deny
"git status*": allow
"git log*": allow
"git diff*": allow
"git show*": allow
"git branch*": allow
"git fetch*": allow
"ls*": allow
"rg *": allow
"grep *": allow
"cat *": allow
"wc *": allow
---

You are the dedicated code reviewer for this repository (AndCode). You review changes like a
maintainer who knows the codebase inside out: you verify claims against the actual code instead of
trusting the diff alone. You never modify files; you only read and report.

## Repository map (verify details against the code when reviewing)

- Android app, single Gradle module `app/`, Kotlin + Jetpack Compose, package root
`com.yugahashimoto.andcode`. JDK 17, AGP 8.x, compileSdk 35.
- `feature/` packages own screens and ViewModels: `chat`, `settings`, `workspace`, `onboarding`,
`schedule`, `assistant` (voice), `wakeword` (Vosk), `widget`, `activity`.
- `runtime/` owns agent backends: `OpenCodeBackend` interface; `runtime/local/` runs a PRoot-based
Linux environment on-device (`LocalRuntimeManager`, `LocalRuntimeInstaller`,
`ClaudeCodeController`, `AntigravityController`). Remote targets exist too.
- `core/` holds cross-cutting code: `core/api` (OpenCode HTTP/SSE client), `core/locale`
(app-language switching), `core/notification`, `core/diagnostics`, `core/security`.
- DI is Koin (`di/` modules) plus hand-rolled `ViewModelFactory` inside composables; both
construction paths must stay in sync when a ViewModel gains a dependency.
- `runtime_tools/` and `scripts/` generate the Android runtime assets; `pages/` is the website.

## Hard rules of this repo (flag violations as blocking)

1. **i18n**: English source is `app/src/main/res/values/strings.xml`; every key must also exist in
every `values-*/strings.xml` (ar, es, fr, ja, pt-rBR, ru, zh-rCN) unless marked
`translatable="false"` — `.github/workflows/i18n-check.yml` fails otherwise. No hardcoded
user-visible text in Kotlin or XML: use `stringResource`/`getString`. LLM prompts and log
messages stay English. ViewModels receive user-visible messages through constructor injection
with an English default (see `WorkspaceViewModel.incompleteConnectionMessage`,
`McpViewModel.authNotRemovedMessage`) or a `*Messages` interface with an
`Android*Messages(context)` implementation (see `LocalRuntimeMessages`).
2. **Formatting/static analysis**: spotless (ktlint 1.2.1) and detekt (`config/detekt/`) run in CI;
code must pass `./gradlew detekt spotlessCheck`.
3. **Tests**: JUnit4 unit tests under `app/src/test`; behavior changes need test updates.
4. **No secrets** in code or config; GitHub OAuth client id comes from build config/env.
5. **Comments**: the repo favors explanatory comments for non-obvious decisions; do not demand
their removal, and do not demand adding boilerplate comments.
6. **Worktree rule**: changes must live on a branch created by `scripts/new-worktree.sh`
(based on `origin/main`), never directly on the main working tree.

## Review procedure

1. Establish scope: `git fetch origin main` then `git diff --stat origin/main...HEAD` and the full
`git diff origin/main...HEAD`. Read every hunk.
2. For each hunk, open the surrounding code (`rg`, reads) to check: callers, tests, DI wiring,
resource keys in all 8 locale files, and any interface/implementation pairs.
3. Evaluate against, in order: correctness, regressions in adjacent behavior, i18n/localization,
Compose recomposition and state pitfalls, concurrency (Flow/coroutine scope leaks), resource
leaks (recognizers, receivers, streams), security (secrets, path traversal, intent handling),
test coverage, repo conventions above.
4. Do not nitpick style that spotless/detekt already enforces. Do not relitigate established
patterns listed above. Prefer fewer, high-signal findings.

## Output format (mandatory)

Respond in Japanese (the maintainer's language) with exactly this structure:

```
判定: APPROVE | REQUEST_CHANGES

## ブロッカー
- <file>:<line> — <problem> — <suggested fix>
(なければ「なし」)

## 提案(非ブロッキング)
- ...
(なければ「なし」)

## チェック済み項目
- <what you verified and how, e.g. 全8ロケールのキー一致を確認>
```

Use `REQUEST_CHANGES` if and only if there is at least one ブロッカー. A finding is a ブロッカー
when it would fail CI, break behavior, regress localization, or violate the hard rules above.
70 changes: 70 additions & 0 deletions .opencode/skills/pre-pr-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
name: pre-pr-review
description: Mandatory review gate before opening a pull request in this repository. Runs local CI gates and the repo-reviewer subagent on the full branch diff, iterates until the reviewer approves, and records the verdict in the PR description. Use whenever you are about to create a PR.
license: MIT
---

# Pre-PR review (mandatory)

This repository requires an approved review BEFORE any pull request is created. The reviewer is
the `repo-reviewer` subagent, which carries the repository's architecture map and hard rules. The
gate is also enforced in CI (`.github/workflows/pr-review-gate.yml`): a PR whose description does
not contain an approved review report fails the check.

## Workflow

1. **Sync and scope the diff**

```bash
git fetch origin main
git diff --stat origin/main...HEAD
```

If the branch has no commits ahead of `origin/main`, stop: there is nothing to review.

2. **Run the local gates first** (cheap failures should never reach the reviewer):

```bash
./gradlew detekt spotlessCheck
```

If `app/src/main/res/values*/strings.xml` changed, also run the key-parity check from
`.github/workflows/i18n-check.yml` (every source key must exist in every locale file).
A full Gradle build may be impossible on-device (x86_64 aapt2 on an arm64 device); that is
expected — CI compiles. Fix everything that can run locally.

3. **Invoke the reviewer** with the task tool, subagent type `repo-reviewer`. Prompt it with:

```
このブランチ(<branch name>、base: origin/main)のPR前レビューをお願いします。
変更概要: <one or two sentences describing the change and why>
```

The subagent reviews `git diff origin/main...HEAD` itself; do not paste the whole diff into the
prompt.

4. **Handle the verdict**

- `REQUEST_CHANGES`: fix every ブロッカー, then re-run from step 3. Repeat until `APPROVE`.
Never open the PR while a ブロッカー is outstanding.
- `APPROVE`: proceed. Consider 提案(非ブロッキング) items; apply the cheap, safe ones.

5. **Record the verdict in the PR description.** The PR body must contain the reviewer's report
verbatim inside the marker block below — the CI gate looks for the first line:

```markdown
<!-- pre-pr-review: approved -->
## Pre-PR review

<paste the reviewer's final report here>
```

If a later push changes the branch materially, re-run this skill and update the block
(`pre-pr-review: approved` must stay truthful for the HEAD commit).

## Rules

- Do not skip, summarize away, or forge the reviewer report; the block must be the subagent's
actual output for the current HEAD.
- If the `repo-reviewer` subagent is unavailable, say so and stop — do not open the PR silently.
- Trivial bot PRs (e.g. Weblate translation sync) are exempt and are skipped by the CI gate.
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ When working on a multi-step task, use the todo feature to track progress.

When work is complete:

1. Create a pull request against `main`.
2. Wait for CI to pass.
3. Merge the PR once CI passes.
1. **Run the mandatory pre-PR review.** Load the `pre-pr-review` skill and follow it exactly:
the `repo-reviewer` subagent must review the full branch diff against `origin/main`, and every
blocking finding must be fixed and re-reviewed until the verdict is `APPROVE`. NEVER open a PR
without this. The CI `PR Review Gate` check fails any PR whose description does not contain the
approved review report (`<!-- pre-pr-review: approved -->`).
2. Create a pull request against `main`, including the reviewer's report as the skill describes.
3. Wait for CI to pass.
4. Merge the PR once CI passes.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ val viewModelModule =
settings = get(),
registry = get(),
voskModels = get(),
providerDisconnectRejectedMessage =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_disconnect_rejected),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ private const val TAG = "SpeechRecognizerManager"
private const val CLEANUP_DELAY_MS = 300L
private const val MAX_RESULTS = 3

// A short pause is common inside a Japanese sentence. Giving the recognizer more time here
// A short pause is common in the middle of a sentence. Giving the recognizer more time here
// avoids finalizing a segment before the user has finished the thought; chat then starts the next
// segment after a genuine recognition result so long dictation remains in one composer value.
//
Expand All @@ -29,22 +29,24 @@ private const val MAX_RESULTS = 3
private const val SILENCE_LENGTH_MS = 3000

/**
* 音声認識マネージャー
* Manages the platform speech recognizer.
*/
class SpeechRecognizerManager(private val context: Context) {
private var recognizer: SpeechRecognizer? = null

/**
* 音声認識を利用可能かチェック
* Checks whether speech recognition is available on this device.
*/
fun isAvailable(): Boolean {
return SpeechRecognizer.isRecognitionAvailable(context)
}

/**
* 音声認識を開始し、結果をFlowで返す
* Starts listening and returns the results as a Flow. The caller must pass the user's
* language tag; there is no default so a missing locale can never silently fall back to
* the wrong language.
*/
fun startListening(language: String = "ja-JP"): Flow<SpeechResult> =
fun startListening(language: String): Flow<SpeechResult> =
callbackFlow {
Log.d(TAG, "startListening called, isAvailable=${isAvailable()}")

Expand Down Expand Up @@ -195,7 +197,7 @@ class SpeechRecognizerManager(private val context: Context) {
}

/**
* 音声認識の結果
* A speech recognition result.
*/
sealed interface SpeechResult {
data object Ready : SpeechResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,9 @@ private fun ChatComposer(
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
filtered.forEach { suggestion ->
val description =
suggestion.descriptionRes?.let { stringResource(it) }
?: suggestion.description
DropdownMenuItem(
text = {
Row(
Expand All @@ -1033,7 +1036,7 @@ private fun ChatComposer(
fontWeight = FontWeight.SemiBold,
)
Text(
text = suggestion.description,
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.yugahashimoto.andcode.feature.chat

private const val HANDOFF_HEADER =
"以下は別の実行先から引き継いだ会話の要約です。続きから対応してください。"
"Below is a summary of a conversation handed over from another session. Continue from where it left off."

fun buildHandoffPrompt(
messages: List<ChatMessage>,
Expand All @@ -11,7 +11,7 @@ fun buildHandoffPrompt(
messages.mapNotNull { message ->
val text = message.text.trim()
if (text.isEmpty()) return@mapNotNull null
val role = if (message.isUser) "ユーザー" else "アシスタント"
val role = if (message.isUser) "User" else "Assistant"
"$role: $text"
}
if (lines.isEmpty()) return HANDOFF_HEADER
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,42 @@
package com.yugahashimoto.andcode.feature.chat

import androidx.annotation.StringRes
import com.yugahashimoto.andcode.R
import com.yugahashimoto.andcode.core.api.OpenCodeCommand
import com.yugahashimoto.andcode.core.api.OpenCodeSkill

enum class SlashAction { NEW_CHAT, CLEAR, MODEL, AGENT, ATTACH, HELP }

data class SlashCommand(
val name: String,
val description: String,
@StringRes val descriptionRes: Int,
val action: SlashAction,
)

/**
* One entry in the composer's slash-command popup: either an app-level command or a command/skill
* the connected backend advertises. All of them end up inserting `/<name> ` into the input; the
* send path then routes backend commands and skills through the runtime's command handling.
*
* App commands carry a string resource so their description follows the app language; backend
* entries carry the text the backend advertised.
*/
sealed interface SlashSuggestion {
val name: String
val description: String

@get:StringRes
val descriptionRes: Int?
get() = null

/**
* [description] is empty by design: UI must resolve [descriptionRes] so the text follows the
* app language. Only backend entries carry literal text.
*/
data class App(val command: SlashCommand) : SlashSuggestion {
override val name: String = command.name
override val description: String = command.description
override val description: String = ""
override val descriptionRes: Int = command.descriptionRes
}
Comment on lines 36 to 40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[maintainability · low]
Appdescription を常に空文字でオーバーライドしていますが、インターフェース SlashSuggestiondescription: String を必須の抽象プロパティとして公開し続けています。descriptionRes の解決は UI 層でしか行われないため、description を直接参照する将来のコード(テスト・ログ・他機能など)はアプリコマンドで黙って空文字を表示してしまいます。descriptionString? にしデフォルトで null(App はオーバーライド不要)とし、Backend のみリテラル文字列を持つ設計にすると、このダミー空文字を排除でき、安全かつ簡潔になります。

Suggestion:

Suggested change
data class App(val command: SlashCommand) : SlashSuggestion {
override val name: String = command.name
override val description: String = command.description
override val description: String = ""
override val descriptionRes: Int = command.descriptionRes
}
data class App(val command: SlashCommand) : SlashSuggestion {
override val name: String = command.name
override val descriptionRes: Int = command.descriptionRes
}


data class Backend(
Expand All @@ -35,12 +49,12 @@ sealed interface SlashSuggestion {
object SlashCommandRegistry {
val commands: List<SlashCommand> =
listOf(
SlashCommand("/new", "Start a new session", SlashAction.NEW_CHAT),
SlashCommand("/clear", "Clear current conversation", SlashAction.CLEAR),
SlashCommand("/model", "Switch model", SlashAction.MODEL),
SlashCommand("/agent", "Switch agent", SlashAction.AGENT),
SlashCommand("/attach", "Attach a file", SlashAction.ATTACH),
SlashCommand("/help", "Show help", SlashAction.HELP),
SlashCommand("/new", R.string.slash_desc_new, SlashAction.NEW_CHAT),
SlashCommand("/clear", R.string.slash_desc_clear, SlashAction.CLEAR),
SlashCommand("/model", R.string.slash_desc_model, SlashAction.MODEL),
SlashCommand("/agent", R.string.slash_desc_agent, SlashAction.AGENT),
SlashCommand("/attach", R.string.slash_desc_attach, SlashAction.ATTACH),
SlashCommand("/help", R.string.slash_desc_help, SlashAction.HELP),
)

/**
Expand Down
Loading
Loading