Refactor startup, queries, and workflow into focused modules (#36)

* chore(backlog): add mining workflow milestone and tasks

* refactor: split character dictionary runtime modules

* refactor: split shared type entrypoints

* refactor: use bun serve for stats server

* feat: add repo-local subminer workflow plugin

* fix: add stats server node fallback

* refactor: split immersion tracker query modules

* chore: update backlog task records

* refactor: migrate shared type imports

* refactor: compose startup and setup window wiring

* Add backlog tasks and launcher time helper tests

- Track follow-up cleanup work in Backlog.md
- Replace Date.now usage with shared nowMs helper
- Add launcher args/parser and core regression tests

* test: increase launcher test timeout for CI stability

* fix: address CodeRabbit review feedback

* refactor(main): extract remaining inline runtime logic from main

* chore(backlog): update task notes and changelog fragment

* refactor: split main boot phases

* test: stabilize bun coverage reporting

* Switch plausible endpoint and harden coverage lane parsing

- update docs-site tracking to use the Plausible capture endpoint
- tighten coverage lane argument and LCOV parsing checks
- make script entrypoint use CommonJS main guard

* Restrict docs analytics and build coverage input

- limit Plausible init to docs.subminer.moe
- build Yomitan before src coverage lane

* fix(ci): normalize Windows shortcut paths for cross-platform tests

* Fix verification and immersion-tracker grouping

- isolate verifier artifacts and lease handling
- switch weekly/monthly tracker cutoffs to calendar boundaries
- tighten boot lifecycle and zip writer tests

* fix: resolve CI type failures in boot and immersion query tests

* fix: remove strict spread usage in Date mocks

* fix: use explicit super args for MockDate constructors

* Factor out mock date helper in tracker tests

- reuse a shared `withMockDate` helper for date-sensitive query tests
- make monthly rollup assertions key off `videoId` instead of row order

* fix: use variadic array type for MockDate constructor args

TS2367: fixed-length tuple made args.length === 0 unreachable.

* refactor: remove unused createMainBootRuntimes/Handlers aggregate functions

These functions were never called by production code — main.ts imports
the individual composeBoot* re-exports directly.

* refactor: remove boot re-export alias layer

main.ts now imports directly from the runtime/composers and runtime/domains
modules, eliminating the intermediate boot/ indirection.

* refactor: consolidate 3 near-identical setup window factories

Extract shared createSetupWindowHandler with a config parameter.
Public API unchanged.

* refactor: parameterize duplicated getAffected*Ids query helpers

Four structurally identical functions collapsed into two parameterized
helpers while preserving the existing public API.

* refactor: inline identity composers (stats-startup, overlay-window)

composeStatsStartupRuntime was a no-op that returned its input.
composeOverlayWindowHandlers was a 1-line delegation.
Both removed in favor of direct usage.

* chore: remove unused token/queue file path constants from main.ts

* fix: replace any types in boot services with proper signatures

* refactor: deduplicate ensureDir into shared/fs-utils

5 copies of mkdir-p-if-not-exists consolidated into one shared module
with ensureDir (directory path) and ensureDirForFile (file path) variants.

* fix: tighten type safety in boot services

- Add AppLifecycleShape and OverlayModalInputStateShape constraints
  so TAppLifecycleApp and TOverlayModalInputState generics are bounded
- Remove unsafe `as { handleModalInputStateChange? }` cast — now
  directly callable via the constraint
- Use `satisfies AppLifecycleShape` for structural validation on the
  appLifecycleApp object literal
- Document Electron App.on incompatibility with simple signatures

* refactor: inline subtitle-prefetch-runtime-composer

The composer was a pure pass-through that destructured an object and
reassembled it with the same fields. Inlined at the call site.

* chore: consolidate duplicate import paths in main.ts

* test: extract mpv composer test fixture factory to reduce duplication

* test: add behavioral assertions to composer tests

Upgrade 8 composer test files from shape-only typeof checks to behavioral
assertions that invoke returned handlers and verify injected dependencies are
actually called, following the mpv-runtime-composer pattern.

* refactor: normalize import extensions in query modules

* refactor: consolidate toDbMs into query-shared.ts

* refactor: remove Node.js fallback from stats-server, use Bun only

* Fix monthly rollup test expectations

- Preserve multi-arg Date construction in mock helper
- Align rollup assertions with the correct videoId

* fix: address PR 36 CodeRabbit follow-ups

* fix: harden coverage lane cleanup

* fix(stats): fallback to node server when Bun.serve unavailable

* fix(ci): restore coverage lane compatibility

* chore(backlog): close TASK-242

* fix: address latest CodeRabbit review round

* fix: guard disabled immersion retention windows

* fix: migrate discord rpc wrapper

* fix(ci): add changelog fragment for PR 36

* fix: stabilize macOS visible overlay toggle

* fix: pin installed mpv plugin to current binary

* fix: strip inline subtitle markup from sidebar cues

* fix(renderer): restore subtitle sidebar mpv passthrough

* feat(discord): add configurable presence style presets

Replace the hardcoded "Mining and crafting (Anki cards)" meme message
with a preset system. New `discordPresence.presenceStyle` option
supports four presets: "default" (clean bilingual), "meme" (the OG
Minecraft joke), "japanese" (fully JP), and "minimal". The default
preset shows "Sentence Mining" with 日本語学習中 as the small image
tooltip. Existing users can set presenceStyle to "meme" to keep the
old behavior.

* fix: finalize v0.10.0 release prep

* docs: add subtitle sidebar guide and release note

* chore(backlog): mark docs task done

* fix: lazily resolve youtube playback socket path

* chore(release): build v0.10.0 changelog

* Revert "chore(release): build v0.10.0 changelog"

This reverts commit 9741c0f020.
This commit is contained in:
2026-03-29 16:16:29 -07:00
committed by GitHub
parent 2d4f2d1139
commit a7e3402d6e
297 changed files with 17713 additions and 9147 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "subminer-local",
"interface": {
"displayName": "SubMiner Local"
},
"plugins": [
{
"name": "subminer-workflow",
"source": {
"source": "local",
"path": "./plugins/subminer-workflow"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}

View File

@@ -1,127 +1,22 @@
---
name: "subminer-change-verification"
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
name: 'subminer-change-verification'
description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.'
---
# SubMiner Change Verification
# Compatibility Shim
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
Canonical source:
## Scripts
- `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md`
- `scripts/classify_subminer_diff.sh`
- Emits suggested lanes and flags from explicit paths or current git changes.
- `scripts/verify_subminer_change.sh`
- Runs selected lanes, captures artifacts, and writes a compact summary.
Canonical helper scripts:
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
```bash
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
```
When this shim is invoked:
## Default workflow
1. Inspect the changed files or user-requested area.
2. Run the classifier unless you already know the right lane.
3. Run the verifier with the cheapest sufficient lane set.
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
6. Return:
- verification summary
- exact commands run
- artifact paths
- skipped lanes and blockers
## Quick start
Repo-source quick start:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Installed-skill quick start:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
```
Classify explicit files:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
launcher/main.ts \
plugin/subminer/lifecycle.lua \
src/main/runtime/mpv-client-runtime-service.ts
```
Run automatic lane selection:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Installed-skill form:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
```
Run targeted lanes:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--lane launcher-plugin \
--lane runtime-compat
```
Dry-run to inspect planned commands and artifact layout:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--dry-run \
launcher/main.ts \
src/main.ts
```
## Lane guidance
- `docs`
- For `docs-site/`, `docs/`, and doc-only edits.
- `config`
- For `src/config/` and config-template-sensitive edits.
- `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin`
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
- `runtime-compat`
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
- `real-runtime`
- Only after deliberate escalation.
## Real Runtime Escalation
Escalate only when the change claim depends on actual runtime behavior, for example:
- overlay appears, hides, or tracks a real mpv window
- mpv launch flags or pause-until-ready behavior
- plugin/socket/auto-start handshake under a real player
- macOS/window-tracker/focus-sensitive behavior
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
## Artifact contract
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
- `summary.json`
- `summary.txt`
- `classification.txt`
- `env.txt`
- `lanes.txt`
- `steps.tsv`
- `steps/*.stdout.log`
- `steps/*.stderr.log`
On failure, quote the exact failing command and point at the artifact directory.
1. Read the canonical plugin-owned skill.
2. Follow the plugin-owned skill as the source of truth.
3. Use the wrapper scripts in this shim directory only for compatibility with existing commands, docs, and backlog history.
4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead.

View File

@@ -1,163 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: classify_subminer_diff.sh [path ...]
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh"
Emit suggested verification lanes for explicit paths or current local git changes.
Output format:
lane:<name>
flag:<name>
reason:<text>
EOF
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
add_lane() {
local lane=$1
if ! has_item "$lane" "${LANES[@]:-}"; then
LANES+=("$lane")
fi
}
add_flag() {
local flag=$1
if ! has_item "$flag" "${FLAGS[@]:-}"; then
FLAGS+=("$flag")
fi
}
add_reason() {
REASONS+=("$1")
}
collect_git_paths() {
local top_level
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
return 0
fi
(
cd "$top_level"
if git rev-parse --verify HEAD >/dev/null 2>&1; then
git diff --name-only --relative HEAD --
git diff --name-only --relative --cached --
else
git diff --name-only --relative --
git diff --name-only --relative --cached --
fi
git ls-files --others --exclude-standard
) | awk 'NF' | sort -u
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
if [[ ! -x "$TARGET" ]]; then
echo "Missing canonical script: $TARGET" >&2
exit 1
fi
declare -a PATHS=()
declare -a LANES=()
declare -a FLAGS=()
declare -a REASONS=()
if [[ $# -gt 0 ]]; then
while [[ $# -gt 0 ]]; do
PATHS+=("$1")
shift
done
else
while IFS= read -r line; do
[[ -n "$line" ]] && PATHS+=("$line")
done < <(collect_git_paths)
fi
if [[ ${#PATHS[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no changed paths detected -> default to core"
fi
for path in "${PATHS[@]}"; do
specialized=0
case "$path" in
docs-site/*|docs/*|changes/*|README.md)
add_lane "docs"
add_reason "$path -> docs"
specialized=1
;;
esac
case "$path" in
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
add_lane "config"
add_reason "$path -> config"
specialized=1
;;
esac
case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin"
add_reason "$path -> launcher-plugin"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
case "$path" in
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
add_lane "runtime-compat"
add_reason "$path -> runtime-compat"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
if [[ "$specialized" == "0" ]]; then
case "$path" in
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
add_lane "core"
add_reason "$path -> core"
;;
esac
fi
case "$path" in
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
add_flag "broad-impact"
add_reason "$path -> broad-impact"
;;
esac
done
if [[ ${#LANES[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no lane-specific matches -> default to core"
fi
for lane in "${LANES[@]}"; do
printf 'lane:%s\n' "$lane"
done
for flag in "${FLAGS[@]}"; do
printf 'flag:%s\n' "$flag"
done
for reason in "${REASONS[@]}"; do
printf 'reason:%s\n' "$reason"
done
exec "$TARGET" "$@"

View File

@@ -1,566 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: verify_subminer_change.sh [options] [path ...]
Options:
--lane <name> Force a verification lane. Repeatable.
--artifact-dir <dir> Use an explicit artifact directory.
--allow-real-runtime Allow explicit real-runtime execution.
--allow-real-gui Deprecated alias for --allow-real-runtime.
--dry-run Record planned steps without executing commands.
--help Show this help text.
If no lanes are supplied, the script classifies the provided paths. If no paths are
provided, it classifies the current local git changes.
Authoritative real-runtime verification should be requested with explicit path
arguments instead of relying on inferred local git changes.
EOF
}
timestamp() {
date +%Y%m%d-%H%M%S
}
timestamp_iso() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
generate_session_id() {
local tmp_dir
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
basename "$tmp_dir"
rmdir "$tmp_dir"
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
normalize_lane_name() {
case "$1" in
real-gui)
printf '%s' "real-runtime"
;;
*)
printf '%s' "$1"
;;
esac
}
add_lane() {
local lane
lane=$(normalize_lane_name "$1")
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
SELECTED_LANES+=("$lane")
fi
}
add_blocker() {
BLOCKERS+=("$1")
BLOCKED=1
}
append_step_record() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
}
record_env() {
{
printf 'repo_root=%s\n' "$REPO_ROOT"
printf 'session_id=%s\n' "$SESSION_ID"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
printf 'dry_run=%s\n' "$DRY_RUN"
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
printf 'session_home=%s\n' "$SESSION_HOME"
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
printf 'pwd=%s\n' "$(pwd)"
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
git status --short 2>/dev/null || true
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf 'requested_paths=\n'
printf ' %s\n' "${PATH_ARGS[@]}"
fi
} >"$ARTIFACT_DIR/env.txt"
}
run_step() {
local lane=$1
local name=$2
local command=$3
local note=${4:-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log"
local stderr_rel="steps/${slug}.stderr.log"
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
local status exit_code
COMMANDS_RUN+=("$command")
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$command" >"$stdout_path"
: >"$stderr_path"
status="dry-run"
exit_code=0
else
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
status="passed"
exit_code=0
EXECUTED_REAL_STEPS=1
else
exit_code=$?
status="failed"
FAILED=1
fi
fi
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
if [[ "$status" == "failed" ]]; then
FAILURE_STEP="$name"
FAILURE_COMMAND="$command"
FAILURE_STDOUT="$stdout_rel"
FAILURE_STDERR="$stderr_rel"
return "$exit_code"
fi
}
record_nonpassing_step() {
local lane=$1
local name=$2
local status=$3
local note=$4
local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log"
local stderr_rel="steps/${slug}.stderr.log"
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
: >"$ARTIFACT_DIR/$stderr_rel"
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
}
record_skipped_step() {
record_nonpassing_step "$1" "$2" "skipped" "$3"
}
record_blocked_step() {
add_blocker "$3"
record_nonpassing_step "$1" "$2" "blocked" "$3"
}
record_failed_step() {
FAILED=1
FAILURE_STEP=$2
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
add_blocker "$3"
record_nonpassing_step "$1" "$2" "failed" "$3"
}
find_real_runtime_helper() {
local candidate
for candidate in \
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
acquire_real_runtime_lease() {
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
local lease_dir="$lease_root/exclusive-real-runtime"
mkdir -p "$lease_root"
if mkdir "$lease_dir" 2>/dev/null; then
REAL_RUNTIME_LEASE_DIR="$lease_dir"
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
return 0
fi
local owner=""
if [[ -f "$lease_dir/session_id" ]]; then
owner=$(cat "$lease_dir/session_id")
fi
add_blocker "real-runtime lease already held${owner:+ by $owner}"
return 1
}
release_real_runtime_lease() {
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
local owner
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
if [[ "$owner" != "$SESSION_ID" ]]; then
return 0
fi
fi
rm -rf "$REAL_RUNTIME_LEASE_DIR"
fi
}
compute_final_status() {
if [[ "$FAILED" == "1" ]]; then
FINAL_STATUS="failed"
elif [[ "$BLOCKED" == "1" ]]; then
FINAL_STATUS="blocked"
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
FINAL_STATUS="passed"
else
FINAL_STATUS="skipped"
fi
}
write_summary_files() {
local lane_lines
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
SESSION_ID_ENV="$SESSION_ID" \
FINAL_STATUS_ENV="$FINAL_STATUS" \
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
SESSION_HOME_ENV="$SESSION_HOME" \
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
STARTED_AT_ENV="$STARTED_AT" \
FINISHED_AT_ENV="$FINISHED_AT" \
FAILED_ENV="$FAILED" \
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
bun -e '
const fs = require("fs");
const path = require("path");
function readLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
}
const artifactDir = process.env.ARTIFACT_DIR_ENV;
const reportsDir = path.join(artifactDir, "reports");
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
return {
lane,
name,
status,
exitCode: Number(exitCode || 0),
command,
stdout,
stderr,
note,
};
});
const summary = {
sessionId: process.env.SESSION_ID_ENV || "",
artifactDir,
reportsDir,
status: process.env.FINAL_STATUS_ENV || "failed",
selectedLanes: lanes,
failed: process.env.FAILED_ENV === "1",
failure:
process.env.FAILED_ENV === "1"
? {
command: process.env.FAILURE_COMMAND_ENV || "",
stdout: process.env.FAILURE_STDOUT_ENV || "",
stderr: process.env.FAILURE_STDERR_ENV || "",
}
: null,
blockers,
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
requestedPaths,
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
startedAt: process.env.STARTED_AT_ENV || "",
finishedAt: process.env.FINISHED_AT_ENV || "",
env: {
home: process.env.SESSION_HOME_ENV || "",
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
},
steps,
};
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
const lines = [];
lines.push(`session_id: ${summary.sessionId}`);
lines.push(`artifact_dir: ${artifactDir}`);
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
lines.push(`status: ${summary.status}`);
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
if (requestedPaths.length > 0) {
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
}
if (blockers.length > 0) {
lines.push(`blockers: ${blockers.join(" | ")}`);
}
for (const step of steps) {
lines.push(`${step.lane}/${step.name}: ${step.status}`);
if (step.command) lines.push(` command: ${step.command}`);
lines.push(` stdout: ${step.stdout}`);
lines.push(` stderr: ${step.stderr}`);
if (step.note) lines.push(` note: ${step.note}`);
}
if (summary.failed) {
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
}
const summaryText = lines.join("\n") + "\n";
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
'
}
cleanup() {
release_real_runtime_lease
}
CLASSIFIER_OUTPUT=""
ARTIFACT_DIR=""
ALLOW_REAL_RUNTIME=0
DRY_RUN=0
FAILED=0
BLOCKED=0
EXECUTED_REAL_STEPS=0
FINAL_STATUS=""
FAILURE_STEP=""
FAILURE_COMMAND=""
FAILURE_STDOUT=""
FAILURE_STDERR=""
REAL_RUNTIME_LEASE_DIR=""
STARTED_AT=""
FINISHED_AT=""
declare -a EXPLICIT_LANES=()
declare -a SELECTED_LANES=()
declare -a PATH_ARGS=()
declare -a COMMANDS_RUN=()
declare -a BLOCKERS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--lane)
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
shift 2
;;
--artifact-dir)
ARTIFACT_DIR=$2
shift 2
;;
--allow-real-runtime|--allow-real-gui)
ALLOW_REAL_RUNTIME=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--help|-h)
usage
exit 0
;;
--)
shift
while [[ $# -gt 0 ]]; do
PATH_ARGS+=("$1")
shift
done
;;
*)
PATH_ARGS+=("$1")
shift
;;
esac
done
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
SESSION_ID=$(generate_session_id)
PATH_SELECTION_MODE="git-inferred"
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
PATH_SELECTION_MODE="explicit"
fi
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh"
if [[ -z "$ARTIFACT_DIR" ]]; then
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
fi
SESSION_HOME="$ARTIFACT_DIR/home"
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
: >"$STEPS_TSV"
trap cleanup EXIT
STARTED_AT=$(timestamp_iso)
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
local_lane=""
for local_lane in "${EXPLICIT_LANES[@]}"; do
add_lane "$local_lane"
done
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
else
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
else
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
fi
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
while IFS= read -r line; do
case "$line" in
lane:*)
add_lane "${line#lane:}"
;;
esac
done <<<"$CLASSIFIER_OUTPUT"
fi
record_env
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
for lane in "${SELECTED_LANES[@]}"; do
case "$lane" in
docs)
run_step "$lane" "docs-test" "bun run docs:test" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "docs-build" "bun run docs:build" || break
;;
config)
run_step "$lane" "test-config" "bun run test:config" || break
;;
core)
run_step "$lane" "typecheck" "bun run typecheck" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-fast" "bun run test:fast" || break
;;
launcher-plugin)
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
;;
runtime-compat)
run_step "$lane" "build" "bun run build" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
;;
real-runtime)
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
record_blocked_step \
"$lane" \
"real-runtime-guard" \
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
break
fi
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
record_blocked_step \
"$lane" \
"real-runtime-guard" \
"real-runtime lane requested but --allow-real-runtime was not supplied"
break
fi
if ! acquire_real_runtime_lease; then
record_blocked_step \
"$lane" \
"real-runtime-lease" \
"real-runtime lease already held; rerun after the active runtime verification finishes"
break
fi
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
record_blocked_step \
"$lane" \
"real-runtime-helper" \
"real-runtime helper not implemented yet"
break
fi
printf -v REAL_RUNTIME_COMMAND \
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
"$SESSION_ID" \
"$SESSION_HOME" \
"$SESSION_XDG_CONFIG_HOME" \
"$SESSION_MPV_LOG" \
"$REAL_RUNTIME_HELPER"
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
;;
*)
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
break
;;
esac
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
break
fi
done
FINISHED_AT=$(timestamp_iso)
compute_final_status
write_summary_files
printf 'status=%s\n' "$FINAL_STATUS"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
case "$FINAL_STATUS" in
failed)
printf 'result=failed\n'
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
if [[ ! -x "$TARGET" ]]; then
echo "Missing canonical script: $TARGET" >&2
exit 1
;;
blocked)
printf 'result=blocked\n'
exit 2
;;
*)
printf 'result=ok\n'
exit 0
;;
esac
fi
exec "$TARGET" "$@"

View File

@@ -1,146 +1,18 @@
---
name: "subminer-scrum-master"
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
name: 'subminer-scrum-master'
description: 'Compatibility shim. Canonical SubMiner scrum-master workflow now lives in the repo-local subminer-workflow plugin.'
---
# SubMiner Scrum Master
# Compatibility Shim
Own workflow, not code by default.
Canonical source:
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
- `plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md`
## Core Rules
When this shim is invoked:
1. Decide first whether backlog tracking is warranted.
2. If backlog is needed, search first. Update existing work when it clearly matches.
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
4. Record a plan before dispatching coding work.
5. Use parent + subtasks for multi-part work when backlog is used.
6. Dispatch conservatively. Parallelize only disjoint write scopes.
7. Require verification before handoff, typically via `subminer-change-verification`.
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
1. Read the canonical plugin-owned skill.
2. Follow the plugin-owned skill as the source of truth.
3. Do not duplicate workflow changes here; update the plugin-owned skill instead.
## Backlog Decision
Skip backlog when the request is:
- question only
- obvious mechanical edit
- tiny isolated change with no real planning
Use backlog when the work:
- needs planning or scope decisions
- spans multiple phases or subsystems
- is likely to need subagent dispatch
- should remain traceable for handoff/resume
If backlog is used:
- search existing tasks first
- create/update a standalone task for one focused deliverable
- create/update a parent task plus subtasks for multi-part work
- record the implementation plan in the task before implementation begins
## Intake Workflow
1. Parse the request.
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
2. Decide whether backlog is needed.
3. If backlog is needed:
- search first
- update existing task if clearly relevant
- otherwise create the right structure
- write the implementation plan before dispatch
4. If backlog is skipped:
- write a short working plan in-thread
- proceed without fake ticketing
5. Choose execution mode:
- no subagents for trivial work
- one worker for focused work
- parallel workers only for disjoint scopes
6. Run verification before handoff.
## Dispatch Rules
The scrum master orchestrates. Workers implement.
- Do not become the default implementer unless delegation is unnecessary.
- Do not parallelize overlapping files or tightly coupled runtime work.
- Give every worker explicit ownership of files/modules.
- Tell every worker other agents may be active and they must not revert unrelated edits.
- Require each worker to report:
- changed files
- tests run
- blockers
Use worker agents for implementation and explorer agents only for bounded codebase questions.
## Verification
Every nontrivial code task gets verification.
Preferred flow:
1. use `subminer-change-verification`
2. start with the cheapest sufficient lane
3. escalate only when needed
4. if worker verification is sufficient, accept it or run one final consolidating pass
Never hand off nontrivial work without stating what was verified and what was skipped.
## Pre-Handoff Policy Checks (Required)
Before handoff, always ask and answer both of these questions explicitly:
1. **Docs update required?**
2. **Changelog fragment required?**
Rules:
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
- If the answer is yes, either complete the update or report the blocker before handoff.
- Include the final answers in the handoff summary even when both answers are "no."
## Failure / Scope Handling
- If a worker hits ambiguity, pause and ask the user.
- If verification fails, either:
- send the worker back with exact failure context, or
- fix it directly if it is tiny and clearly in scope
- If new scope appears, revisit backlog structure before silently expanding work.
## Representative Flows
### Trivial no-ticket work
- decide backlog is unnecessary
- keep a short plan
- implement directly or with one worker if helpful
- run targeted verification
- report outcome concisely
### Single-task implementation
- search/create/update one task
- record plan
- dispatch one worker
- integrate
- verify
- update task and report outcome
### Parent + subtasks execution
- search/create/update parent task
- create subtasks for distinct deliverables/phases
- record sequencing in the plan
- dispatch workers only where scopes are disjoint
- integrate
- run consolidated verification
- update task state and report outcome
## Output Expectations
At the end, report:
- whether backlog was used and what changed
- which workers were dispatched and what they owned
- what verification ran
- explicit answers to:
- docs update required?
- changelog fragment required?
- blockers, skips, and risks
This shim exists so existing repo references and prompts keep resolving during the migration to the repo-local plugin workflow.

View File

@@ -61,6 +61,16 @@ jobs:
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src

View File

@@ -49,6 +49,16 @@ jobs:
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ out/
dist/
release/
build/yomitan/
coverage/
# Launcher build artifact (produced by make build-launcher)
/subminer

View File

@@ -83,7 +83,6 @@ This project uses Backlog.md MCP for all task and project management activities.
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization

266
Backlog.md Normal file
View File

@@ -0,0 +1,266 @@
# Backlog
Purpose: lightweight repo-local task board. Seeded with current testing / coverage work.
Status keys:
- `todo`: not started
- `doing`: in progress
- `blocked`: waiting
- `done`: shipped
Priority keys:
- `P0`: urgent / release-risk
- `P1`: high value
- `P2`: useful cleanup
- `P3`: nice-to-have
## Active
| ID | Pri | Status | Area | Title |
| ------ | --- | ------ | -------------- | --------------------------------------------------- |
| SM-013 | P1 | done | review-followup | Address PR #36 CodeRabbit action items |
## Ready
| ID | Pri | Status | Area | Title |
| ------ | --- | ------ | ----------------- | ---------------------------------------------------------------- |
| SM-001 | P1 | todo | launcher | Add tests for CLI parser and args normalizer |
| SM-002 | P1 | todo | immersion-tracker | Backfill tests for uncovered query exports |
| SM-003 | P1 | todo | anki | Add focused field-grouping service + merge edge-case tests |
| SM-004 | P2 | todo | tests | Extract shared test utils for deps factories and polling helpers |
| SM-005 | P2 | todo | tests | Strengthen weak assertions in app-ready and IPC tests |
| SM-006 | P2 | todo | tests | Break up monolithic youtube-flow and subtitle-sidebar tests |
| SM-007 | P2 | todo | anilist | Add tests for AniList rate limiter |
| SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests |
| SM-009 | P3 | todo | tokenizer | Add tests for JLPT token filter |
| SM-010 | P1 | todo | immersion-tracker | Refactor storage + immersion-tracker service into focused modules |
| SM-011 | P1 | done | tests | Add coverage reporting for maintained test lanes |
| SM-012 | P2 | done | config/runtime | Replace JSON serialize-clone helpers with structured cloning |
## Icebox
None.
## Ticket Details
### SM-001
Title: Add tests for CLI parser and args normalizer
Priority: P1
Status: done
Scope:
- `launcher/config/cli-parser-builder.ts`
- `launcher/config/args-normalizer.ts`
Acceptance:
- root options parsing covered
- subcommand routing covered
- invalid action / invalid log level / invalid backend cases covered
- target classification covered: file, directory, URL, invalid
### SM-002
Title: Backfill tests for uncovered query exports
Priority: P1
Status: todo
Scope:
- `src/core/services/immersion-tracker/query-*.ts`
Targets:
- headword helpers
- anime/media detail helpers not covered by existing wrapper tests
- lexical detail / appearance helpers
- maintenance helpers beyond `deleteSession` and `upsertCoverArt`
Acceptance:
- every exported query helper either directly tested or explicitly justified as covered elsewhere
- at least one focused regression per complex SQL branch / aggregation branch
### SM-003
Title: Add focused field-grouping service + merge edge-case tests
Priority: P1
Status: todo
Scope:
- `src/anki-integration/field-grouping.ts`
- `src/anki-integration/field-grouping-merge.ts`
Acceptance:
- auto/manual/disabled flow branches covered
- duplicate-card preview failure path covered
- merge edge cases covered: empty fields, generated media fallback, strict grouped spans, audio synchronization
### SM-004
Title: Extract shared test utils for deps factories and polling helpers
Priority: P2
Status: todo
Scope:
- common `makeDeps` / `createDeps` helpers
- common `waitForCondition`
Acceptance:
- shared helper module added
- at least 3 duplicated polling helpers removed
- at least 5 duplicated deps factories consolidated or clearly prepared for follow-up migration
### SM-005
Title: Strengthen weak assertions in app-ready and IPC tests
Priority: P2
Status: todo
Scope:
- `src/core/services/app-ready.test.ts`
- `src/core/services/ipc.test.ts`
Acceptance:
- replace broad `assert.ok(...)` presence checks with exact value / order assertions where expected value known
- handler registration tests assert channel-specific behavior, not only existence
### SM-006
Title: Break up monolithic youtube-flow and subtitle-sidebar tests
Priority: P2
Status: todo
Scope:
- `src/main/runtime/youtube-flow.test.ts`
- `src/renderer/modals/subtitle-sidebar.test.ts`
Acceptance:
- reduce single-test breadth
- split largest tests into focused cases by behavior
- keep semantics unchanged
### SM-007
Title: Add tests for AniList rate limiter
Priority: P2
Status: todo
Scope:
- `src/core/services/anilist/rate-limiter.ts`
Acceptance:
- capacity-window wait behavior covered
- `x-ratelimit-remaining` + reset handling covered
- `retry-after` handling covered
### SM-008
Title: Add core subtitle-position persistence/path tests
Priority: P3
Status: todo
Scope:
- `src/core/services/subtitle-position.ts`
Acceptance:
- save/load persistence covered
- fallback behavior covered
- path normalization behavior covered for URL vs local target
### SM-009
Title: Add tests for JLPT token filter
Priority: P3
Status: todo
Scope:
- `src/core/services/jlpt-token-filter.ts`
Acceptance:
- excluded term membership covered
- ignored POS1 membership covered
- exported list / entry consistency covered
### SM-010
Title: Refactor storage + immersion-tracker service into focused layers without API changes
Priority: P1
Status: todo
Scope:
- `src/core/database/storage/storage.ts`
- `src/core/database/storage/schema.ts`
- `src/core/database/storage/cover-blob.ts`
- `src/core/database/storage/records.ts`
- `src/core/database/storage/write-path.ts`
- `src/core/services/immersion-tracker/youtube.ts`
- `src/core/services/immersion-tracker/youtube-manager.ts`
- `src/core/services/immersion-tracker/write-queue.ts`
- `src/core/services/immersion-tracker/immersion-tracker-service.ts`
Acceptance:
- behavior and public API remain unchanged for all callers
- `storage.ts` responsibilities split into DDL/migrations, cover blob helpers, record CRUD, and write-path execution
- `immersion-tracker-service.ts` reduces to session state, media change orchestration, query proxies, and lifecycle
- YouTube code split into pure utilities, a stateful manager (`YouTubeManager`), and a dedicated write queue (`WriteQueue`)
- removed `storage.ts` is replaced with focused modules and updated imports
- no API or migration regressions; existing tests for trackers/storage coverage remain green or receive focused updates
### SM-011
Title: Add coverage reporting for maintained test lanes
Priority: P1
Status: done
Scope:
- `package.json`
- CI workflow files under `.github/`
- `docs/workflow/verification.md`
Acceptance:
- at least one maintained test lane emits machine-readable coverage output
- CI surfaces coverage as an artifact, summary, or check output
- local contributor path for coverage is documented
- chosen coverage path works with Bun/TypeScript lanes already maintained by the repo
Implementation note:
- Added `bun run test:coverage:src` for the maintained source lane via a sharded coverage runner, with merged LCOV output at `coverage/test-src/lcov.info` and CI/release artifact upload as `coverage-test-src`.
### SM-012
Title: Replace JSON serialize-clone helpers with structured cloning
Priority: P2
Status: todo
Scope:
- `src/runtime-options.ts`
- `src/config/definitions.ts`
- `src/config/service.ts`
- `src/main/controller-config-update.ts`
Acceptance:
- runtime/config clone helpers stop using `JSON.parse(JSON.stringify(...))`
- replacement preserves current behavior for plain config/runtime objects
- focused tests cover clone/merge behavior that could regress during the swap
- no new clone helper is introduced in these paths without a documented reason
Done:
- replaced JSON serialize-clone call sites in runtime/config/controller update paths with `structuredClone`
- updated focused tests and fixtures to cover detached clone behavior and guard against regressions
### SM-013
Title: Address PR #36 CodeRabbit action items
Priority: P1
Status: done
Scope:
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
- `scripts/subminer-change-verification.test.ts`
- `src/core/services/immersion-tracker/query-sessions.ts`
- `src/core/services/immersion-tracker/query-trends.ts`
- `src/core/services/immersion-tracker/maintenance.ts`
- `src/main/boot/services.ts`
- `src/main/character-dictionary-runtime/zip.test.ts`
Acceptance:
- fix valid open CodeRabbit findings on PR #36
- add focused regression coverage for behavior changes where practical
- verify touched tests plus typecheck stay green
Done:
- hardened `--artifact-dir` validation in the verification script
- fixed trend aggregation rounding and monthly ratio bucketing
- preserved unwatched anime episodes in episode queries
- restored seconds-based aggregate timestamps in shared maintenance
- fixed the startup refactor compile break by making the predicates local at the call site
- verified with `bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts` and `bun run typecheck`

View File

@@ -1,5 +1,26 @@
# Changelog
## v0.10.0 (2026-03-29)
### Changed
- Integrations: Replaced the deprecated Discord Rich Presence wrapper with the maintained `@xhayper/discord-rpc` package.
### Fixed
- Stats: Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Stats: Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.
- Overlay: Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
- Subtitle Sidebar: Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
### Internal
- Release: Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
- Release: CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.
- Runtime: Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
- Runtime: Added focused regression tests for the extracted runtime/composer boundaries.
- Runtime: Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.
- Runtime: Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
- Runtime: Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
- Runtime: Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.
## v0.9.3 (2026-03-25)
### Changed

View File

@@ -0,0 +1,35 @@
---
id: TASK-243
title: 'Assess and address PR #36 latest CodeRabbit review round'
status: Done
assignee: []
created_date: '2026-03-29 07:39'
updated_date: '2026-03-29 07:41'
labels:
- code-review
- pr-36
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/36'
priority: high
ordinal: 3600
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Inspect the latest CodeRabbit review round on PR #36, verify each actionable comment against the current branch, implement the confirmed fixes, and verify the touched paths.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Confirmed review comments are implemented or explicitly deferred with rationale.
- [ ] #2 Touched paths are verified with the smallest sufficient test/build lane.
- [ ] #3 Current PR feedback is reduced to resolved or intentionally deferred suggestions.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the confirmed latest CodeRabbit review items on PR #36. `scripts/run-coverage-lane.ts` now uses the Bun-style `import.meta.main` entrypoint check with a local ts-ignore to preserve the repo's CommonJS typecheck settings. `src/core/services/immersion-tracker/maintenance.ts` no longer shadows the imported `nowMs` helper in retention functions. `src/main.ts` now centralizes the startup-mode predicates behind a shared helper and releases `resolvedSource.cleanup` on the cached-subtitle fast path so materialized sources do not leak.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,35 @@
---
id: TASK-244
title: 'Assess and address PR #36 latest CodeRabbit review round 2'
status: Done
assignee: []
created_date: '2026-03-29 08:09'
updated_date: '2026-03-29 08:10'
labels:
- code-review
- pr-36
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/36'
priority: high
ordinal: 3610
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Inspect the newest CodeRabbit review round on PR #36, verify the actionable comment against the current branch, implement the confirmed fix, and verify the touched path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The actionable review comment is implemented or explicitly deferred with rationale.
- [ ] #2 Touched path is verified with the smallest sufficient test lane.
- [ ] #3 Current PR feedback is reduced to resolved or intentionally deferred suggestions.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the actionable latest CodeRabbit comment on PR #36. `src/core/services/immersion-tracker/maintenance.ts` now skips retention deletions when a window is disabled with `Infinity`, so `toDbMs(...)` is only called for finite retention values. Added a regression test in `maintenance.test.ts` that verifies disabled retention windows preserve session events, telemetry, and sessions while returning zero deletions.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,11 +1,11 @@
project_name: 'SubMiner'
default_status: 'To Do'
statuses: ['To Do', 'In Progress', 'Done']
project_name: "SubMiner"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
definition_of_done: []
date_format: yyyy-mm-dd
max_column_width: 20
default_editor: 'nvim'
default_editor: "nvim"
auto_open_browser: false
default_port: 6420
remote_operations: true
@@ -13,4 +13,4 @@ auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: 'task'
task_prefix: "task"

View File

@@ -0,0 +1,8 @@
---
id: m-2
title: 'Mining Workflow Upgrades'
---
## Description
Future user-facing workflow improvements that directly improve discoverability, previewability, and mining control without depending on speculative platform integrations like OCR, marketplace infrastructure, or cloud sync.

View File

@@ -0,0 +1,59 @@
---
id: TASK-238
title: Codebase health follow-up: decompose remaining oversized runtime surfaces
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- tech-debt
- maintainability
- runtime
milestone: m-0
dependencies: []
references:
- src/main.ts
- src/types.ts
- src/main/character-dictionary-runtime.ts
- src/core/services/immersion-tracker/query.ts
- backlog/tasks/task-87 - Codebase-health-harden-verification-and-retire-dead-architecture-identified-in-the-March-2026-review.md
- backlog/completed/task-87.4 - Runtime-composition-root-remove-dead-symbols-and-tighten-module-boundaries-in-src-main.ts.md
- backlog/completed/task-87.6 - Anki-integration-maintainability-continue-decomposing-the-oversized-orchestration-layer.md
- backlog/tasks/task-238.6 - Extract-remaining-inline-runtime-logic-and-composer-gaps-from-src-main.ts.md
- backlog/tasks/task-238.7 - Split-src-main.ts-into-boot-phase-services-runtimes-and-handlers.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Follow up the March 2026 codebase-health work with a narrower pass over the biggest remaining production hotspots. The latest review correctly flags `src/main.ts` and `src/types.ts` as maintainability pressure, but it also misses the next real large surfaces that will keep slowing future work: `src/main/character-dictionary-runtime.ts` and `src/core/services/immersion-tracker/query.ts`. This parent task should track focused decomposition work that preserves behavior, avoids redoing already-completed dead-architecture cleanup, and keeps each slice small enough for isolated implementation.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Child tasks exist for each focused cleanup slice instead of one broad “split the monoliths” effort.
- [ ] #2 The parent task records sequencing so agents do not overlap on `src/main.ts` and other shared surfaces.
- [ ] #3 The selected follow-up tasks target still-live pressure points, not already-completed work like TASK-87.4, TASK-87.5, or TASK-87.6.
- [ ] #4 Completion of the child tasks leaves runtime wiring, shared types, character-dictionary orchestration, and immersion-tracker queries materially easier to review and extend.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Recommended sequencing:
1. Start TASK-238.3 first. A compatibility-first type split reduces churn risk for the later runtime/query refactors.
2. Run TASK-238.4 and TASK-238.5 in parallel after TASK-238.3 if desired; they touch different domains.
3. Run TASK-238.1 after or alongside the domain refactors, but keep it focused on window/bootstrap composition only.
4. Run TASK-238.2 after TASK-238.1 because both touch `src/main.ts` and the CLI/headless flow should build on the cleaner composition root.
5. Run TASK-238.6 after the current composer/setup-window-factory work lands, so the remaining inline runtime logic and composer gaps are extracted from the already-cleaned composition root.
6. Run TASK-238.7 only after TASK-238.6 confirms the remaining entrypoint surface still justifies a boot-phase split; then move the boot wiring into dedicated service/runtime/handler modules.
Shared guardrails:
- Do not reopen already-completed dead-module cleanup from TASK-87.5 unless new evidence appears.
- Keep `src/types.ts` migration compatibility-first; avoid a repo-wide import churn bomb.
- Prefer extracting named runtime/domain modules over moving code into new giant helper files.
- Verify each slice with the cheapest sufficient lane, then escalate when a task crosses runtime/build boundaries.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-238.1
title: Extract main-window and overlay-window composition from src/main.ts
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- tech-debt
- runtime
- windows
- maintainability
milestone: m-0
dependencies: []
references:
- src/main.ts
- src/main/runtime/composers
- src/main/runtime/overlay-runtime-bootstrap.ts
- docs/architecture/README.md
parent_task_id: TASK-238
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/main.ts` still directly owns several `BrowserWindow` construction and window-lifecycle paths, including overlay-adjacent windows and setup flows. That keeps the composition root far larger than intended and makes window behavior hard to test in isolation. Extract the remaining window/bootstrap composition into named runtime modules so `src/main.ts` mostly wires dependencies and app lifecycle events together.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 At least the main overlay window path plus two other window/setup flows are extracted from direct `BrowserWindow` construction inside `src/main.ts`.
- [ ] #2 The extracted modules expose narrow factory/handler APIs that can be tested without booting the whole app.
- [ ] #3 `src/main.ts` becomes materially smaller and easier to scan, with window creation concentrated behind well-named runtime surfaces.
- [ ] #4 Relevant runtime/window tests pass, and new tests are added for any newly isolated window composition helpers.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Map the remaining direct `BrowserWindow` creation sites in `src/main.ts` and group them by shared lifecycle concerns.
2. Extract coherent modules for construction, preload/path resolution, and open/focus/reuse behavior rather than moving raw option objects wholesale.
3. Update the composition root to consume the new modules and keep side effects/app state ownership explicit.
4. Verify with focused runtime/window tests plus `bun run typecheck`.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-238.2
title: Extract CLI and headless command wiring from src/main.ts
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- tech-debt
- cli
- runtime
- maintainability
milestone: m-0
dependencies:
- TASK-238.1
references:
- src/main.ts
- src/main/cli-runtime.ts
- src/cli/args.ts
- launcher
parent_task_id: TASK-238
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/main.ts` still owns the headless-initial-command flow, argument handling, and a large amount of CLI/runtime bridging. That makes non-window startup paths difficult to reason about and keeps CLI behavior coupled to unrelated desktop boot logic. Extract the remaining CLI/headless orchestration into dedicated runtime services so the main entrypoint only decides which startup path to invoke.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 CLI parsing, initial-command dispatch, and headless command execution no longer live as large inline flows in `src/main.ts`.
- [ ] #2 The new modules make the desktop startup path and headless startup path visibly separate and easier to test.
- [ ] #3 Existing CLI behaviors remain unchanged, including help output and startup gating behavior.
- [ ] #4 Targeted CLI/runtime tests cover the extracted path, and `bun run typecheck` passes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Map the current `parseArgs` / `handleInitialArgs` / `runHeadlessInitialCommand` / `handleCliCommand` flow in `src/main.ts`.
2. Extract a small startup-path selector plus dedicated runtime services for headless execution and interactive startup dispatch.
3. Keep Electron app ownership in `src/main.ts`; move only CLI orchestration and context assembly.
4. Verify with CLI-focused tests plus `bun run typecheck`.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-238.3
title: Introduce domain type entrypoints and shrink src/types.ts import surface
status: Done
assignee: []
created_date: '2026-03-26 20:49'
updated_date: '2026-03-27 00:14'
labels:
- tech-debt
- types
- maintainability
milestone: m-0
dependencies: []
references:
- src/types.ts
- src/shared/ipc/contracts.ts
- src/config/service.ts
- docs/architecture/README.md
parent_task_id: TASK-238
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/types.ts` has become the repo-wide dumping ground for unrelated domains. Splitting it is still worthwhile, but a big-bang move would create noisy churn across a large import graph. Introduce domain entrypoints under `src/types/` and migrate the highest-churn imports first while leaving `src/types.ts` as a compatibility barrel until the new structure is proven.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Domain-focused type modules exist for the main clusters currently mixed together in `src/types.ts` (for example Anki, config/runtime, subtitle/media, and integration/runtime-option types).
- [x] #2 `src/types.ts` becomes a thinner compatibility layer or barrel instead of the sole source of truth for every shared type.
- [x] #3 A meaningful set of imports is migrated to the new entrypoints without breaking the maintained typecheck/test lanes.
- [x] #4 The new structure is documented well enough that contributors can tell where new shared types should live.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inventory the main type clusters in `src/types.ts` and choose stable domain seams.
2. Create `src/types/` modules and re-export through `src/types.ts` so the migration can be incremental.
3. Migrate the highest-value import sites first, especially config/runtime and Anki-heavy surfaces.
4. Verify with `bun run typecheck` and the cheapest test lane covering touched domains.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented domain entrypoints under `src/types/` and kept `src/types.ts` as a compatibility barrel (`src/types/anki.ts`, `src/types/config.ts`, `src/types/integrations.ts`, `src/types/runtime.ts`, `src/types/runtime-options.ts`, `src/types/subtitle.ts`). Migrated the highest-value import surfaces away from `src/types.ts` in config/runtime/Anki-related modules and shared IPC surfaces. Added type-level regression coverage in `src/types-domain-entrypoints.type-test.ts`.
Aligned docs in `docs/architecture/README.md`, `docs/architecture/domains.md`, and `docs-site/changelog.md` to support the change and clear docs-site sync mismatch.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Task completed with commit `5dd8bb7f` (`refactor: split shared type entrypoints`). The refactor introduced domain type entrypoints, shrank the `src/types.ts` import surface, updated import consumers, and recorded verification evidence in the local verifier artifacts. Backlog now tracks TASK-238.3 as done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,58 @@
---
id: TASK-238.4
title: Decompose character dictionary runtime into fetch, build, and cache modules
status: Done
updated_date: '2026-03-27 00:20'
assignee: []
created_date: '2026-03-26 20:49'
labels:
- tech-debt
- runtime
- anilist
- maintainability
milestone: m-0
dependencies:
- TASK-238.3
references:
- src/main/character-dictionary-runtime.ts
- src/main/runtime/character-dictionary-auto-sync.ts
- docs/architecture/README.md
parent_task_id: TASK-238
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/main/character-dictionary-runtime.ts` is now one of the largest live production files in the repo and combines AniList transport, name normalization, snapshot/image shaping, cache management, and zip packaging. That file will keep growing as character-dictionary features evolve. Split it into focused modules so the runtime surface becomes orchestration instead of a catch-all implementation blob.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 AniList fetch/parsing logic, dictionary-entry building, and snapshot/cache/zip persistence no longer live in one giant file.
- [x] #2 The public runtime API stays behavior-compatible for current callers.
- [x] #3 The top-level runtime/orchestration file becomes materially smaller and easier to review.
- [x] #4 Existing character-dictionary tests still pass, and new focused tests cover the extracted modules where needed.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Identify the dominant concern boundaries inside `src/main/character-dictionary-runtime.ts`.
2. Extract fetch/transform/persist modules with narrow interfaces, keeping data-shape ownership explicit.
3. Leave the exported runtime API stable for current main-process callers.
4. Verify with the maintained character-dictionary/runtime test lane plus `bun run typecheck`.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Split `src/main/character-dictionary-runtime.ts` into focused modules under `src/main/character-dictionary-runtime/` (`fetch`, `build`, `cache`, plus helper modules). The orchestrator stayed as a compatibility shim/API surface with delegated module functions. Added focused tests for cache snapshot semantics and term rebuild + collapsible-open-state behavior in the new modules. Updated runtime architecture docs in `docs/architecture/domains.md` and `docs-site/architecture.md`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Task completed with commit `5b06579e` (`refactor: split character dictionary runtime modules`). Runtime refactor landed with regression coverage and verification including runtime-compat lanes, and all changed behavior was validated as API-compatible for callers.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,61 @@
---
id: TASK-238.5
title: Split immersion tracker query layer into focused read-model modules
status: Done
assignee:
- codex
created_date: '2026-03-26 20:49'
updated_date: '2026-03-27 00:00'
labels:
- tech-debt
- stats
- database
- maintainability
milestone: m-0
dependencies:
- TASK-238.3
references:
- src/core/services/immersion-tracker/query.ts
- src/core/services/stats-server.ts
- src/core/services/immersion-tracker-service.ts
parent_task_id: TASK-238
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/core/services/immersion-tracker/query.ts` has grown into a large mixed read/write/maintenance surface that owns library queries, timeline/detail queries, cleanup helpers, and rollup rebuild hooks. That size makes stats work harder to change safely. Split the query layer into focused read-model and maintenance modules so future stats/dashboard work does not keep landing in one 2500-line file.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
- [x] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
- [x] #3 The refactor preserves current SQL behavior and existing statistics outputs.
- [x] #4 Existing stats/immersion tests still pass, with added focused coverage where extraction creates new seams.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inventory the major query clusters and choose modules that match current caller boundaries.
2. Extract without changing schema or response contracts unless a narrow cleanup is required for compile/test health.
3. Keep SQL ownership close to the domain module that consumes it; avoid a giant `queries/` dump with no structure.
4. Verify with the maintained stats/immersion test lane plus `bun run typecheck`.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Split the monolithic query surface into focused read-model modules for sessions, trends, lexical data, library lookups, and maintenance helpers. Updated the service and test imports to use the new module boundaries.
Verification: `bun run typecheck` passed. Focused query and stats-server tests passed, including the `stats-server.test.ts` coverage around the new Bun fallback path.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Extracted the immersion-tracker query layer into smaller read-model modules and kept the compatibility barrel in place so existing call sites can transition cleanly. Added focused coverage and verified the refactor with typecheck plus targeted tests.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,84 @@
---
id: TASK-238.6
title: Extract remaining inline runtime logic and composer gaps from src/main.ts
status: Done
assignee: []
created_date: '2026-03-27 00:00'
updated_date: '2026-03-27 22:13'
labels:
- tech-debt
- runtime
- maintainability
- composers
milestone: m-0
dependencies:
- TASK-238.1
- TASK-238.2
references:
- src/main.ts
- src/main/runtime/youtube-flow.ts
- src/main/runtime/autoplay-ready-gate.ts
- src/main/runtime/subtitle-prefetch-init.ts
- src/main/runtime/discord-presence-runtime.ts
- src/main/overlay-modal-state.ts
- src/main/runtime/composers
parent_task_id: TASK-238
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/main.ts` still mixes two concerns: pure dependency wiring and inline runtime logic. The earlier composer extractions reduce the wiring burden, but the file still owns several substantial behavior blocks and a few large inline dependency groupings. This task tracks the next maintainability pass: move the remaining runtime logic into the appropriate domain modules, add missing composer wrappers for the biggest grouped handler blocks, and reassess whether a boot-phase split is still necessary after the entrypoint becomes mostly wiring.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
- [x] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`.
- [x] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules.
- [x] #4 Focused tests cover the extracted behavior or the new composer surfaces.
- [x] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Recommended sequence:
1. Let the current composer and `setup-window-factory` work land first so this slice starts from a stable wiring baseline.
2. Extract the five inline runtime functions into their natural domain modules or direct equivalents.
3. Add or extend composer helpers for subtitle/prefetch, stats startup, and overlay visibility handler grouping.
4. Re-scan `src/main.ts` after the extraction and decide whether a boot-phase split is still the right next task.
5. Verify the extracted behavior with focused tests first, then run the relevant broader runtime gate if the slice crosses startup boundaries.
Guardrails:
- Keep the work behavior-preserving.
- Prefer moving logic to existing runtime surfaces over creating new giant helper files.
- Do not expand into unrelated `src/main.ts` cleanup that is already tracked by other TASK-238 slices.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Extracted the remaining inline runtime seams from `src/main.ts` into focused runtime modules:
`src/main/runtime/youtube-playback-runtime.ts`,
`src/main/runtime/autoplay-ready-gate.ts`,
`src/main/runtime/subtitle-prefetch-runtime.ts`,
`src/main/runtime/discord-presence-runtime.ts`,
and `src/main/runtime/overlay-modal-input-state.ts`.
Added named composer wrappers for the grouped subtitle/prefetch, stats startup, and overlay visibility wiring in `src/main/runtime/composers/`.
Re-scan result for the boot-phase split follow-up: the entrypoint is materially closer to a boot/lifecycle coordinator now, so TASK-238.7 remains a valid future cleanup but no longer feels urgent or blocking for maintainability.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-238.6 is complete. Verification passed with `bun run typecheck`, focused runtime/composer tests, `bun run test:fast`, `bun run test:env`, and `bun run build`. The remaining `src/main.ts` work is now better isolated behind runtime modules and composer helpers, and the boot-phase split can wait for a later cleanup pass instead of being treated as immediate follow-on work.
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.6-main-runtime-refactor.md` under runtime internals.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,85 @@
---
id: TASK-238.7
title: Split src/main.ts into boot-phase services, runtimes, and handlers
status: Done
assignee: []
created_date: '2026-03-27 00:00'
updated_date: '2026-03-27 22:45'
labels:
- tech-debt
- runtime
- maintainability
- architecture
milestone: m-0
dependencies:
- TASK-238.6
references:
- src/main.ts
- src/main/boot/services.ts
- src/main/boot/runtimes.ts
- src/main/boot/handlers.ts
- src/main/runtime/composers
parent_task_id: TASK-238
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
After the remaining inline runtime logic and composer gaps are extracted, `src/main.ts` should be split along boot-phase boundaries so the entrypoint stops mixing service construction, domain runtime composition, and handler wiring in one file. This task tracks that structural split: move service instantiation, runtime composition, and handler orchestration into dedicated boot modules, then leave `src/main.ts` as a thin lifecycle coordinator with clear startup-path selection.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
- [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
- [x] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection.
- [x] #4 Existing startup behavior remains unchanged across desktop and headless flows.
- [x] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Recommended sequence:
1. Re-scan `src/main.ts` after TASK-238.6 lands and mark the remaining boot-phase seams by responsibility.
2. Extract service instantiation into `src/main/boot/services.ts` or equivalent.
3. Extract runtime composition into `src/main/boot/runtimes.ts` or equivalent.
4. Extract handler/composer orchestration into `src/main/boot/handlers.ts` or equivalent.
5. Shrink `src/main.ts` to startup-path selection, app lifecycle hooks, and minimal boot wiring.
6. Verify the split with focused entrypoint/runtime tests first, then run the broader runtime gate if the refactor crosses startup boundaries.
Guardrails:
- Keep the split behavior-preserving.
- Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer.
- Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added boot-phase modules under `src/main/boot/`:
`services.ts` for config/user-data/runtime-registry/overlay bootstrap service construction,
`runtimes.ts` for named runtime/composer entrypoints and grouped boot-phase seams,
and `handlers.ts` for handler/composer boot entrypoints.
Rewired `src/main.ts` to source boot-phase service construction from `createMainBootServices(...)` and to route runtime/handler composition through boot-level exports instead of keeping the entrypoint as the direct owner of every composition import.
Added focused tests for the new boot seams in
`src/main/boot/services.test.ts`,
`src/main/boot/runtimes.test.ts`,
and `src/main/boot/handlers.test.ts`.
Updated internal architecture docs to note that `src/main/boot/` now owns boot-phase assembly seams so `src/main.ts` can stay centered on lifecycle coordination and startup-path selection.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-238.7 is complete. Verification passed with focused boot tests, `bun run typecheck`, `bun run test:fast`, and `bun run build`. `src/main.ts` still acts as the composition root, but the boot-phase split now moves service instantiation, runtime composition seams, and handler composition seams into dedicated `src/main/boot/*` modules so the entrypoint reads more like a lifecycle coordinator than a single monolithic bootstrap file.
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.7-main-boot-split.md` for the internal runtime architecture pass.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,51 @@
---
id: TASK-239
title: Mining workflow upgrades: prioritize high-value user-facing improvements
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- feature
- ux
- planning
milestone: m-2
dependencies: []
references:
- src/main.ts
- src/renderer
- src/anki-integration.ts
- src/config/service.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the next set of high-value workflow improvements surfaced by the March 2026 review. The goal is to capture bounded, implementation-sized feature slices with clear user value and avoid prematurely committing to much larger bets like hard-sub OCR, plugin marketplace infrastructure, or cloud config sync. Focus this parent task on features that improve the core mining workflow directly: profile-aware setup, action discoverability, previewing output before mining, and selecting richer subtitle ranges.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Child tasks exist for the selected near-to-medium-term workflow upgrades with explicit scope and exclusions.
- [ ] #2 The parent task records the recommended sequencing so future work starts with the best value/risk ratio.
- [ ] #3 The tracked feature set stays grounded in existing product surfaces instead of speculative external-platform integrations.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Recommended sequencing:
1. Start TASK-239.3 first. Template preview is the smallest high-signal UX win on a core mining path.
2. Start TASK-239.2 next. A command palette improves discoverability across existing actions without large backend upheaval.
3. Start TASK-239.4 after the preview/palette work. Sentence clipping is high-value but touches runtime, subtitle selection, and card creation flows together.
4. Keep TASK-239.1 as a foundation project and scope it narrowly to local multi-profile support. Do not expand it into cloud sync in the same slice.
Deliberate exclusions for now:
- hard-sub OCR
- plugin marketplace infrastructure
- cloud/device sync
- site-specific streaming source auto-detection beyond narrow discovery spikes
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-239.1
title: Add profile-aware config foundations and profile selection flow
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- feature
- config
- launcher
- ux
milestone: m-2
dependencies: []
references:
- src/config/service.ts
- src/config/load.ts
- launcher/config.ts
- src/main.ts
parent_task_id: TASK-239
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Introduce the foundation for local multi-profile use so users can keep separate setups for different workflows without hand-editing or swapping config files manually. Keep the first slice intentionally narrow: named local profiles, explicit selection, separate config/data paths, and safe migration from the current single-profile setup. Do not couple this task to cloud sync or remote profile sharing.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Users can create/select a named local profile and launch SubMiner against that profile explicitly.
- [ ] #2 Each profile uses separate config and data storage paths for settings and profile-scoped runtime state that should not bleed across workflows.
- [ ] #3 Existing single-profile users migrate safely to a default profile without losing settings.
- [ ] #4 The active profile is visible in the launcher/app surface where it materially affects user behavior.
- [ ] #5 Tests cover profile resolution, migration/defaulting behavior, and at least one end-to-end selection path.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Design a minimal profile storage layout and resolution strategy that works for launcher and desktop runtime entrypoints.
2. Add profile selection plumbing before changing feature behavior inside individual services.
3. Migrate config/data-path resolution to be profile-aware while preserving a safe default-profile fallback.
4. Verify with config/launcher tests plus targeted runtime coverage.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-239.2
title: Add a searchable command palette for desktop actions
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- feature
- ux
- desktop
- shortcuts
milestone: m-2
dependencies: []
references:
- src/renderer
- src/shared/ipc/contracts.ts
- src/main/runtime/overlay-runtime-options.ts
- src/main.ts
parent_task_id: TASK-239
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
SubMiner already exposes many actions through scattered shortcuts, menus, and modal flows. Add a searchable command palette so users can discover and execute high-value desktop actions from one keyboard-first surface. Build on the existing runtime-options/modal infrastructure where practical instead of creating a completely separate interaction model.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A keyboard-accessible command palette opens from the desktop app and lists supported actions with searchable labels.
- [ ] #2 Commands are backed by an explicit registry so action availability and labels are not hard-coded in one renderer component.
- [ ] #3 Users can navigate and execute commands entirely from the keyboard.
- [ ] #4 The first slice includes the highest-value existing actions rather than trying to cover every possible command on day one.
- [ ] #5 Tests cover command filtering, execution dispatch, and at least one disabled/unavailable command state.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Define a small command-registry contract shared across renderer and main-process dispatch.
2. Reuse existing modal/runtime plumbing where it fits so the palette is a thin discoverability layer over current actions.
3. Ship a narrow but useful initial command set, then expand later based on usage.
4. Verify with renderer tests plus targeted IPC/runtime tests.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-239.3
title: Add live Anki template preview for card output
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- feature
- anki
- ux
milestone: m-2
dependencies: []
references:
- src/anki-integration.ts
- src/anki-integration/card-creation.ts
- src/config/resolve/anki-connect.ts
- src/renderer
parent_task_id: TASK-239
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Users currently have to infer what card output will look like from config fields and post-mine results. Add a live preview surface that shows the resolved card template output before mining so users can catch broken field mappings, missing media, or undesirable formatting earlier.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Users can open a preview that renders the resolved front/back field output for the current note/card template configuration.
- [ ] #2 The preview clearly surfaces missing or unmapped fields instead of silently showing blank content.
- [ ] #3 Preview generation uses the same transformation logic as the live card-creation path so it stays trustworthy.
- [ ] #4 The first slice works with representative sample mining payloads and handles missing optional media gracefully.
- [ ] #5 Tests cover preview rendering for at least one valid and one invalid/missing-field configuration.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Identify the current card-creation data path and extract any logic needed to render a preview without duplicating transformation rules.
2. Add a focused preview UI in the most relevant existing configuration/setup surface.
3. Surface validation/warning states for empty mappings, missing fields, and media-dependent outputs.
4. Verify with Anki integration tests plus renderer coverage for preview states.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-239.4
title: Add sentence clipping from arbitrary subtitle ranges
status: To Do
assignee: []
created_date: '2026-03-26 20:49'
labels:
- feature
- subtitle
- anki
- ux
milestone: m-2
dependencies: []
references:
- src/renderer/modals/subtitle-sidebar.ts
- src/main/runtime/subtitle-position.ts
- src/anki-integration/card-creation.ts
- src/main/runtime/mpv-main-event-actions.ts
parent_task_id: TASK-239
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Current mining flows are optimized around the active subtitle line. Add a sentence-clipping workflow that lets users select an arbitrary contiguous subtitle range, preview the combined text/timing, and mine from that selection. This should improve multi-line dialogue capture without forcing manual copy/paste or separate post-processing.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Users can select a contiguous subtitle range from the existing subtitle UI instead of being limited to the active cue.
- [ ] #2 The workflow previews the combined text and resulting timing range before mining.
- [ ] #3 Mining from a clipped range uses the combined subtitle payload in card generation while preserving existing single-line behavior.
- [ ] #4 The feature handles overlapping/edge timing cases predictably and does not corrupt the normal active-cue flow.
- [ ] #5 Tests cover range selection, combined payload generation, and at least one card-creation path using a clipped selection.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Define a selection model that fits the existing subtitle sidebar/runtime data flow.
2. Add preview + confirmation UI before routing the clipped payload into mining.
3. Keep the existing single-line path intact and treat clipping as an additive workflow.
4. Verify with subtitle-sidebar, runtime, and Anki/card-creation tests.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,81 @@
---
id: TASK-240
title: Migrate SubMiner agent skills into a repo-local plugin workflow
status: Done
assignee:
- codex
created_date: '2026-03-26 00:00'
updated_date: '2026-03-26 23:23'
labels:
- skills
- plugin
- workflow
- backlog
- tooling
dependencies:
- TASK-159
- TASK-160
priority: high
ordinal: 24000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Turn the current SubMiner-specific repo skills into a reproducible repo-local plugin workflow. The plugin should become the canonical source of truth for the SubMiner scrum-master and change-verification skills, bundle the scripts and metadata needed to test and validate changes, and preserve compatibility for existing repo references through thin `.agents/skills/` shims while the migration settles.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A repo-local plugin scaffold exists for the SubMiner workflow, with manifest and marketplace metadata wired according to the repo-local plugin layout.
- [x] #2 `subminer-scrum-master` and `subminer-change-verification` live under the plugin as the canonical skill sources, along with any helper scripts or supporting files needed for reproducible use.
- [x] #3 Existing repo-level `.agents/skills/` entrypoints are reduced to compatibility shims or redirects instead of remaining as duplicate sources of truth.
- [x] #4 The plugin-owned workflow explicitly documents backlog-first orchestration and change verification expectations, including how the skills work together.
- [x] #5 The migration is validated with the cheapest sufficient repo-native verification lane and the task records the exact commands and any skips/blockers.
<!-- SECTION:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect the plugin-creator contract and current repo skill/script layout, then choose the plugin name, directory structure, and migration boundaries.
2. Scaffold a repo-local plugin plus marketplace entry, keeping the plugin payload under `plugins/<name>/` and the catalog entry under `.agents/plugins/marketplace.json`.
3. Move the two SubMiner-specific skills and their helper scripts into the plugin as the canonical source, adding any plugin docs or supporting metadata needed for reproducible testing/validation.
4. Replace the existing `.agents/skills/subminer-*` surfaces with minimal compatibility shims that point agents at the plugin-owned sources without duplicating logic.
5. Update internal docs or references that should now describe the plugin-first workflow.
6. Run the cheapest sufficient verification lane for plugin/internal-doc changes and record the results in this task.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-26: User approved the migration shape where the plugin becomes the canonical source of truth and `.agents/skills/` stays only as compatibility shims. Repo-local plugin chosen over home-local plugin.
2026-03-26: Backlog MCP resources/tools are not available in this Codex session (`MCP startup failed`), so this task is being initialized directly in the repo-local `backlog/` files instead of through the live Backlog MCP interface.
2026-03-26: Scaffolded `plugins/subminer-workflow/` plus `.agents/plugins/marketplace.json`, moved the scrum-master and change-verification skill definitions into the plugin as the canonical sources, and converted the old `.agents/skills/` surfaces into compatibility shims. Preserved the old verifier script entrypoints as wrappers because backlog/docs history already calls them directly.
2026-03-26: Verification passed.
- `bash -n plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `bash -n plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
- `bash -n .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `bash -n .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh plugins/subminer-workflow/.codex-plugin/plugin.json docs/workflow/agent-plugins.md .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs plugins/subminer-workflow .agents/skills/subminer-scrum-master/SKILL.md .agents/skills/subminer-change-verification/SKILL.md .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh .agents/plugins/marketplace.json docs/workflow/README.md docs/workflow/agent-plugins.md 'backlog/tasks/task-240 - Migrate-SubMiner-agent-skills-into-a-repo-local-plugin-workflow.md'`
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260326-232300-E2NQVX/`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Created a repo-local `subminer-workflow` plugin as the canonical packaging for the SubMiner scrum-master and change-verification workflow. The plugin now owns both skills, the verifier helper scripts, plugin metadata, and workflow docs. The old `.agents/skills/` surfaces remain only as compatibility shims, and the old verifier script paths now forward to the plugin-owned scripts so existing docs and backlog commands continue to work. Targeted plugin/docs verification passed, including wrapper-script syntax checks and a real verifier run through the legacy entrypoint.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,37 @@
id: TASK-241
title: Add optional setup action to seed SubMiner mpv profile
type: feature
status: Open
assignee: []
created_date: '2026-03-27 11:22'
updated_date: '2026-03-27 11:22'
labels:
- setup
- mpv
- docs
- ux
dependencies: []
references: []
documentation:
- /home/sudacode/projects/japanese/SubMiner/docs-site/usage.md
- /home/sudacode/projects/japanese/SubMiner/docs-site/launcher-script.md
ordinal: 24100
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add an optional control in the first-run / setup flow to write or update the users mpv configuration with SubMiner-recommended defaults (especially the `subminer` profile), so users can recover from a missing profile without manual config editing.
The docs for launcher usage must explicitly state that SubMiners Windows mpv launcher path runs mpv with `--profile=subminer` by default.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Add an optional setup UI action/button to generate or overwrite a user-confirmed mpv config that includes a `subminer` profile.
- [ ] #2 The action should be non-destructive by default, show diff/contents before write, and support append/update mode when other mpv settings already exist.
- [ ] #3 Document how to resolve the missing-profile scenario and clearly state that the SubMiner mpv launcher runs with `--profile=subminer` by default (`--launch-mpv` / Windows mpv shortcut path).
- [ ] #4 Add/adjust setup validation messaging so users are not blocked if `subminer` profile is initially missing, but can opt into one-click setup recovery.
- [ ] #5 Include a short verification path for both Windows and non-Windows flows (for example dry-run + write path).
<!-- AC:END -->

View File

@@ -0,0 +1,35 @@
---
id: TASK-242
title: Fix stats server Bun fallback in coverage lane
status: Done
assignee: []
created_date: '2026-03-29 07:31'
updated_date: '2026-03-29 07:37'
labels:
- ci
- bug
milestone: cleanup
dependencies: []
references:
- 'PR #36'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Coverage CI fails when `startStatsServer` reaches the Bun server seam under the maintained source lane. Add a runtime fallback that works when `Bun.serve` is unavailable and keep the stats-server startup path testable.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `bun run test:coverage:src` passes in GitHub CI
- [x] #2 `startStatsServer` uses `Bun.serve` when present and a Node server fallback otherwise
- [x] #3 Regression coverage exists for the fallback startup path
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the CI failure in the coverage lane by replacing the Bun-only stats server path with a Bun-or-node/http startup fallback and by normalizing setup window options so undefined BrowserWindow fields are omitted. Verified the exact coverage lane under Bun 1.3.5 and confirmed the GitHub Actions run for PR #36 completed successfully.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,68 @@
---
id: TASK-245
title: Cut minor release v0.10.0 for docs and release prep
status: Done
assignee:
- '@codex'
created_date: '2026-03-29 08:10'
updated_date: '2026-03-29 08:13'
labels:
- release
- docs
- minor
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/package.json
- /home/sudacode/projects/japanese/SubMiner/README.md
- /home/sudacode/projects/japanese/SubMiner/docs/RELEASING.md
- /home/sudacode/projects/japanese/SubMiner/docs/README.md
- /home/sudacode/projects/japanese/SubMiner/docs-site/changelog.md
- /home/sudacode/projects/japanese/SubMiner/CHANGELOG.md
- /home/sudacode/projects/japanese/SubMiner/release/release-notes.md
priority: high
ordinal: 54850
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare the next 0-ver minor release cut as `v0.10.0`, keeping release-facing docs, backlog, and changelog artifacts aligned, then run the release-prep verification gate.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository version metadata is updated to `0.10.0`.
- [x] #2 Release-facing docs and public changelog surfaces are aligned for the `v0.10.0` cut.
- [x] #3 `CHANGELOG.md` and `release/release-notes.md` contain the committed `v0.10.0` section and any consumed fragments are removed.
- [x] #4 Release-prep verification passes for changelog, config example, typecheck, tests, and build.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Bumped `package.json` from `0.9.3` to `0.10.0`.
- Ran `bun run changelog:build --version 0.10.0 --date 2026-03-29`, which generated `CHANGELOG.md` and `release/release-notes.md` and removed the queued `changes/*.md` fragments.
- Updated `docs-site/changelog.md` with the public-facing `v0.10.0` summary.
Verification:
- `bun run changelog:lint`
- `bun run changelog:check --version 0.10.0`
- `bun run verify:config-example`
- `bun run typecheck`
- `bunx bun@1.3.5 run test:fast`
- `bunx bun@1.3.5 run test:env`
- `bunx bun@1.3.5 run build`
- `bunx bun@1.3.5 run docs:test`
- `bunx bun@1.3.5 run docs:build`
Notes:
- The local `bun` binary is `1.3.11`, which tripped Bun's nested `node:test` handling in `test:fast`; rerunning with the repo-pinned `bun@1.3.5` cleared the issue.
- No README content change was necessary for this cut.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared the `v0.10.0` release cut locally. Bumped `package.json`, generated committed root changelog and release notes, updated the public docs changelog summary, and verified the release gate with the repo-pinned Bun `1.3.5` runtime. The release prep is green and ready for tagging/publishing when desired.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,55 @@
---
id: TASK-246
title: Migrate Discord Rich Presence to maintained RPC wrapper
status: Done
assignee: []
created_date: '2026-03-29 08:17'
updated_date: '2026-03-29 08:22'
labels:
- dependency
- discord
- presence
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the deprecated Discord Rich Presence wrapper with a maintained JavaScript alternative while preserving the current IPC-based presence behavior in the Electron main process.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The app no longer depends on `discord-rpc`
- [x] #2 Discord Rich Presence still logs in and publishes activity updates from the main process
- [x] #3 Existing Discord presence tests continue to pass or are updated to cover the new client API
- [x] #4 The change is documented in the release notes or changelog fragment
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Swapped the app's Discord RPC dependency from `discord-rpc` to `@xhayper/discord-rpc`.
- Extracted the client adapter into `src/main/runtime/discord-rpc-client.ts` so the main process can keep using a small wrapper around the maintained library.
- Added `src/main/runtime/discord-rpc-client.test.ts` to verify the adapter forwards login/activity/clear/destroy calls through `client.user`.
- Documented the dependency swap in `CHANGELOG.md`, `release/release-notes.md`, and `docs-site/changelog.md`.
Verification:
- `bunx bun@1.3.5 test src/main/runtime/discord-rpc-client.test.ts src/core/services/discord-presence.test.ts`
- `bunx bun@1.3.5 run changelog:lint`
- `bunx bun@1.3.5 run changelog:check --version 0.10.0`
- `bunx bun@1.3.5 run docs:test`
- `bunx bun@1.3.5 run docs:build`
Notes:
- The existing release prep artifacts for v0.10.0 were kept intact and updated in place.
- No README change was needed for this dependency swap.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Replaced the deprecated `discord-rpc` dependency with the maintained `@xhayper/discord-rpc` wrapper while preserving the main-process rich presence flow. Added a focused runtime wrapper test, kept the existing Discord presence service tests green, and documented the dependency swap in the release notes and changelog.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,60 @@
---
id: TASK-247
title: Strip inline subtitle markup from subtitle sidebar cues
status: Done
assignee:
- codex
created_date: '2026-03-29 10:01'
updated_date: '2026-03-29 10:10'
labels: []
dependencies: []
references:
- src/core/services/subtitle-cue-parser.ts
- src/renderer/modals/subtitle-sidebar.ts
- src/core/services/subtitle-cue-parser.test.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Subtitle sidebar should display readable subtitle text when loaded subtitle files include inline markup such as HTML-like font tags. Parsed cue text currently preserves markup, causing raw tags to appear in the sidebar instead of clean subtitle content.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Subtitle sidebar cue text omits inline subtitle markup such as HTML-like font tags while preserving visible subtitle content.
- [x] #2 Parsed subtitle cues used by the sidebar keep timing order and expected line-break behavior after markup sanitization.
- [x] #3 Regression tests cover markup-bearing subtitle cue parsing so raw tags do not reappear in the sidebar.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests in src/core/services/subtitle-cue-parser.test.ts for subtitle cues containing HTML-like font tags, including multi-line content.
2. Verify the new parser test fails against current behavior to confirm the bug is covered.
3. Update src/core/services/subtitle-cue-parser.ts to sanitize inline subtitle markup while preserving visible text and expected newline handling.
4. Re-run focused parser tests, then run broader verification commands required for handoff as practical.
5. Update task notes/acceptance criteria based on verified results and finalize the task record.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved implementation on 2026-03-29.
Implemented parser-level subtitle cue sanitization for HTML-like tags so loaded sidebar cues render readable text while preserving cue line breaks.
Added regression coverage for SRT and ASS cue parsing with <font ...> markup.
Verification: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Sanitized parsed subtitle cue text in src/core/services/subtitle-cue-parser.ts so HTML-like inline markup such as <font ...> is removed before cues reach the subtitle sidebar. The sanitizer is shared across SRT/VTT-style parsing and ASS parsing, while existing cue timing and line-break semantics remain intact.
Added regression tests in src/core/services/subtitle-cue-parser.test.ts covering markup-bearing SRT lines and ASS dialogue lines with \N breaks, and verified the original failure before implementing the fix.
Tests run: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,69 @@
---
id: TASK-248
title: Fix macOS visible overlay toggle getting immediately restored
status: Done
assignee: []
created_date: '2026-03-29 10:03'
updated_date: '2026-03-29 22:14'
labels: []
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/ui.lua
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/cli-command.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix the visible overlay toggle path on macOS so the user can reliably hide the overlay after it has been shown. The current behavior can ignore the toggle or hide the overlay briefly before it is restored immediately.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Pressing the visible-overlay toggle hides the overlay when it is currently shown on macOS.
- [x] #2 A manual hide is not immediately undone by startup or readiness flows.
- [x] #3 The mpv/plugin toggle path matches the intended visible-overlay toggle behavior.
- [x] #4 Regression tests cover the failing toggle path.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the toggle/re-show logic from code paths around mpv plugin control commands and auto-play readiness.
2. Add regression coverage for manual toggle-off staying hidden through readiness completion.
3. Patch the plugin/control path so manual visible-overlay toggles are not undone by readiness auto-show.
4. Run targeted tests, then the relevant verification lane.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: the mpv plugin readiness callback (`subminer-autoplay-ready`) could re-issue `--show-visible-overlay` after a manual toggle/hide. Initial fix only suppressed the next readiness restore, but repeated readiness callbacks in the same media session could still re-show the overlay. The plugin toggle path also still used legacy `--toggle` instead of the explicit visible-overlay command.
Implemented a session-scoped suppression flag in the Lua plugin so a manual hide/toggle during the pause-until-ready window blocks readiness auto-show for the rest of the current auto-start session, then resets on the next auto-start session.
Added Lua regression coverage for both behaviors: manual toggle-off stays hidden through readiness completion, repeated readiness callbacks in the same session stay suppressed, and `subminer-toggle` emits `--toggle-visible-overlay` rather than legacy `--toggle`.
Follow-up investigation found a second issue in `src/core/services/cli-command.ts`: pure visible-overlay toggle commands still ran the MPV connect/start path (`connectMpvClient`) because `--toggle` and `--toggle-visible-overlay` were classified as start-like commands. That side effect could retrigger startup visibility work even after the plugin-side fix.
Updated CLI command handling so only `--start` reconnects MPV. Pure toggle/show/hide overlay commands still initialize overlay runtime when needed, but they no longer restart/reconnect the MPV control path.
Renderer/modal follow-ups: restored focused-overlay mpv y-chord proxy in `src/renderer/handlers/keyboard.ts`, added a modal-close guard in `src/main/overlay-runtime.ts` so modal teardown does not re-show a manually hidden overlay, and added a duplicate-toggle debounce in `src/main/runtime/overlay-visibility-actions.ts` to ignore near-simultaneous toggle requests inside the main process.
2026-03-29: added regression for repeated subminer-autoplay-ready signals after manual y-t hide. Root cause: Lua plugin suppression only blocked the first ready-time restore, so later ready callbacks in the same media session could re-show the visible overlay. Updated plugin suppression to remain active for the full current auto-start session and reset on the next auto-start trigger.
2026-03-29: live mpv log showed repeated `subminer-autoplay-ready` script messages from Electron during paused startup, each triggering plugin `--show-visible-overlay` and immediate re-show. Fixed `src/main/runtime/autoplay-ready-gate.ts` so plugin readiness is signaled once per media while paused retry loops only re-issue `pause=false` instead of re-signaling readiness.
2026-03-29: Added window-level guard for stray visible-overlay re-show on macOS. `src/core/services/overlay-window.ts` now immediately re-hides the visible overlay window on `show` if overlay state is false, covering native/Electron re-show paths that bypass normal visibility actions. Regression: `src/core/services/overlay-window.test.ts`. Verified with full gate and rebuilt unsigned mac bundle.
2026-03-29: added a blur-path guard for the visible overlay window. `src/core/services/overlay-window.ts` now skips topmost restacking when a visible-overlay blur fires after overlay state already flipped off, covering a macOS hide-in-flight path that could immediately reassert the window. Regression coverage added in `src/core/services/overlay-window.test.ts`; verified with targeted overlay tests, full gate, and rebuilt unsigned mac bundle.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Confirmed with user that macOS `y-t` now works. Cleaned the patch set down to the remaining justified fixes: explicit visible-overlay plugin toggle/suppression, pure-toggle CLI no longer reconnects MPV, autoplay-ready signaling only fires once per media, and the final visible-overlay blur guard that stops macOS restacking after a manual hide. Full gate passed again before commit `c939c580` (`fix: stabilize macOS visible overlay toggle`).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,37 @@
---
id: TASK-249
title: Fix AniList token persistence on setup login
status: Done
assignee: []
created_date: '2026-03-29 10:08'
updated_date: '2026-03-29 19:42'
labels:
- anilist
- bug
dependencies: []
documentation:
- src/main/runtime/anilist-setup.ts
- src/core/services/anilist/anilist-token-store.ts
- src/main/runtime/anilist-token-refresh.ts
- docs-site/anilist-integration.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList setup can appear successful but the token is not persisted across restarts. Investigate the setup callback and token store path so the app either saves the token reliably or surfaces persistence failure instead of reopening setup on every launch.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 AniList setup login persists a usable token across app restarts when safeStorage works
- [ ] #2 If token persistence fails the setup flow reports the failure instead of pretending login succeeded
- [ ] #3 Regression coverage exists for the callback/save path and the refresh path that reopens setup when no token is available
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Pinned installed mpv plugin configs to the current SubMiner binary so standalone mpv launches reuse the same app identity that saved AniList tokens. Added startup self-heal for existing blank binary_path configs, install-time binary_path writes for fresh plugin installs, regression tests for both paths, and docs updates describing the new behavior.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,72 @@
---
id: TASK-250
title: Restore macOS mpv passthrough while overlay subtitle sidebar is open
status: Done
assignee:
- '@codex'
created_date: '2026-03-29 10:10'
updated_date: '2026-03-29 10:23'
labels:
- bug
- macos
- subtitle-sidebar
- overlay
- mpv
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/keyboard.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.test.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When the overlay-layout subtitle sidebar is open on macOS, users should still be able to click through outside the sidebar and return keyboard focus to mpv so native mpv keybindings continue to work. The sidebar should stay interactive when hovered or focused, but it must not make the whole visible overlay behave like a blocking modal.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Opening the overlay-layout subtitle sidebar does not keep the entire visible overlay mouse-interactive outside sidebar hover or focus.
- [x] #2 With the subtitle sidebar open, clicking outside the sidebar can refocus mpv so native mpv keybindings continue to work.
- [x] #3 Focused regression coverage exists for overlay-layout sidebar passthrough behavior on mouse-ignore state changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add renderer regression coverage for overlay-layout subtitle sidebar passthrough so open-but-unhovered sidebar no longer holds global mouse interaction.
2. Update overlay mouse-ignore gating to keep the subtitle sidebar interactive only while hovered or otherwise actively interacting, instead of treating overlay layout as a blocking modal.
3. Run focused renderer tests for subtitle sidebar and mouse-ignore behavior, then update task notes/criteria with the verified outcome.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Confirmed the regression only affects the default overlay-layout subtitle sidebar: open sidebar state was treated as a blocking overlay modal, which prevented click-through outside the sidebar and stranded native mpv keybindings until focus was manually recovered.
Added a failing regression in src/renderer/modals/subtitle-sidebar.test.ts for overlay-layout passthrough before changing the gate.
Verification: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
User reported the first renderer-only fix did not resolve the macOS issue in practice. Reopening investigation to trace visible-overlay window focus and hit-testing outside the renderer mouse-ignore gate.
Follow-up root cause: sidebar hover handlers were attached to the full-screen `.subtitle-sidebar-modal` shell instead of the actual sidebar panel. On the transparent visible overlay that shell spans the viewport, so sidebar-active state could persist outside the panel and keep the overlay interactive longer than intended.
Updated the sidebar modal to track hover/focus on `subtitleSidebarContent` and derive sidebar interaction state from panel hover or focus-within before recomputing mouse passthrough.
Verification refresh: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored overlay subtitle sidebar passthrough in two layers. First, the visible overlay mouse-ignore gate no longer treats the subtitle sidebar as a global blocking modal. Second, the sidebar panel now tracks interaction on the real sidebar content instead of the full-screen modal shell, and keeps itself active only while the panel is hovered or focused. Added regressions for overlay-layout passthrough and focus-within behavior. Verification: `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts` and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,32 @@
---
id: TASK-251
title: 'Docs: add subtitle sidebar and Jimaku integration pages'
status: Done
assignee: []
created_date: '2026-03-29 22:36'
updated_date: '2026-03-29 22:38'
labels:
- docs
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the docs-site update that adds a dedicated subtitle sidebar page, links Jimaku integration from the homepage/config docs, and refreshes the docs-site theme styling used by those pages.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 docs-site nav includes a Subtitle Sidebar entry
- [x] #2 Subtitle Sidebar page documents layout, shortcut, and config options
- [x] #3 Jimaku integration page and configuration docs link to the new docs page
- [x] #4 Changelog fragment exists for the user-visible docs release note
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added the subtitle sidebar docs page and nav entry, linked Jimaku integration from the homepage/config docs, refreshed docs-site styling tokens, and recorded the release note fragment. Verified with `bun run changelog:lint`, `bun run docs:test`, `bun run docs:build`, and `bun run build`. Full repo test gate still has pre-existing failures in `bun run test:fast` and `bun run test:env` unrelated to these docs changes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -7,10 +7,9 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@hono/node-server": "^1.19.11",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"libsql": "^0.5.22",
@@ -38,6 +37,12 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
@@ -110,8 +115,6 @@
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -146,6 +149,10 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
@@ -174,6 +181,10 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@@ -212,8 +223,6 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -296,7 +305,7 @@
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="],
"discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
@@ -362,8 +371,6 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
@@ -480,6 +487,8 @@
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
@@ -526,8 +535,6 @@
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
@@ -590,8 +597,6 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"register-scheme": ["register-scheme@github:devsnek/node-register-scheme#e7cc9a6", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.3.0" } }, "devsnek-node-register-scheme-e7cc9a6", "sha512-VwUWN3aKIg/yn7T8axW20Y1+4wGALIQectBmkmwSJfLrCycpVepGP/+KHjXSL/Ga8N1SmewL49kESgIhW7HbWg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
@@ -676,14 +681,16 @@
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
@@ -702,10 +709,6 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -772,8 +775,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"discord-rpc/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"electron/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],

View File

@@ -0,0 +1,6 @@
type: docs
area: docs-site
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
- Linked Jimaku integration from the homepage to its dedicated docs page.
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.

View File

@@ -0,0 +1,5 @@
type: fixed
area: main
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.

View File

@@ -498,6 +498,7 @@
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

@@ -74,7 +74,9 @@ export default {
{ text: 'Configuration', link: '/configuration' },
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
{ text: 'JLPT Vocabulary Bundle', link: '/jlpt-vocab-bundle' },
{ text: 'Troubleshooting', link: '/troubleshooting' },
],
},

View File

@@ -10,13 +10,18 @@ let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
const PLAUSIBLE_DOMAIN = 'subminer.moe';
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event';
const PLAUSIBLE_ENABLED_HOSTNAMES = new Set(['docs.subminer.moe']);
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture';
async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return;
}
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
return;
}
const { init } = await import('@plausible-analytics/tracker');
init({
domain: PLAUSIBLE_DOMAIN,

View File

@@ -34,6 +34,25 @@
system-ui,
sans-serif;
--tui-transition: 180ms ease;
/* Theme-specific values — overridden in .dark below */
--tui-nav-bg: color-mix(in srgb, var(--vp-c-bg-alt) 88%, transparent);
--tui-table-hover-bg: color-mix(in srgb, var(--vp-c-bg-soft) 80%, transparent);
--tui-link-underline: color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
--tui-selection-bg: hsla(267, 83%, 45%, 0.14);
--tui-hero-glow: hsla(267, 83%, 45%, 0.05);
--tui-step-hover-bg: var(--vp-c-bg-alt);
--tui-step-hover-glow: color-mix(in srgb, var(--vp-c-brand-1) 30%, transparent);
}
.dark {
--tui-nav-bg: hsla(232, 23%, 18%, 0.82);
--tui-table-hover-bg: hsla(232, 23%, 18%, 0.4);
--tui-link-underline: hsla(267, 83%, 80%, 0.3);
--tui-selection-bg: hsla(267, 83%, 80%, 0.22);
--tui-hero-glow: hsla(267, 83%, 80%, 0.06);
--tui-step-hover-bg: hsla(232, 23%, 18%, 0.6);
--tui-step-hover-glow: hsla(267, 83%, 80%, 0.3);
}
:root {
@@ -48,7 +67,7 @@
/* === Selection === */
::selection {
background: hsla(267, 83%, 80%, 0.22);
background: var(--tui-selection-bg);
color: var(--vp-c-text-1);
}
@@ -102,7 +121,7 @@ button,
}
.VPNav .VPNavBar:not(.has-sidebar) {
background: hsla(232, 23%, 18%, 0.82);
background: var(--tui-nav-bg);
}
.VPNav .VPNavBar.has-sidebar .content {
@@ -245,13 +264,13 @@ button,
}
.vp-doc table tr:hover td {
background: hsla(232, 23%, 18%, 0.4);
background: var(--tui-table-hover-bg);
}
/* === Links === */
.vp-doc a {
text-decoration: none;
border-bottom: 1px solid hsla(267, 83%, 80%, 0.3);
border-bottom: 1px solid var(--tui-link-underline);
transition: border-color var(--tui-transition), color var(--tui-transition);
}
@@ -653,7 +672,7 @@ body {
height: 400px;
background: radial-gradient(
ellipse at center,
hsla(267, 83%, 80%, 0.06) 0%,
var(--tui-hero-glow) 0%,
transparent 70%
);
pointer-events: none;

View File

@@ -39,6 +39,7 @@ src/
types.ts # Shared type definitions
main/ # Main-process composition/runtime adapters
app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
character-dictionary-runtime.ts # Character-dictionary orchestration/public runtime API
cli-runtime.ts # CLI command runtime service adapters
config-validation.ts # Startup/hot-reload config error formatting and fail-fast helpers
dependencies.ts # Shared dependency builders for IPC/runtime services
@@ -53,6 +54,7 @@ src/
startup-lifecycle.ts # Lifecycle runtime runner adapter
state.ts # Application runtime state container + reducer transitions
subsync-runtime.ts # Subsync command runtime adapter
character-dictionary-runtime/ # Character-dictionary fetch/build/cache modules + focused tests
runtime/
composers/ # High-level composition clusters used by main.ts
domains/ # Domain barrel exports (startup/overlay/mpv/jellyfin/...)

View File

@@ -1,5 +1,19 @@
# Changelog
## v0.10.0 (2026-03-29)
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
- Updated Discord Rich Presence to the maintained `@xhayper/discord-rpc` wrapper.
- Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
- Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
## v0.9.3 (2026-03-25)
- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
- Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly.
- Removed the old internal YouTube retime helper and its tests.
- Clarified optional `alass` / `ffsubsync` subtitle-sync setup and fallback behavior in the docs.
- Removed the legacy `youtubeSubgen.primarySubLanguages` config path from generated config and docs.
## v0.9.2 (2026-03-25)
- Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions.
- Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates.

View File

@@ -390,6 +390,8 @@ The sidebar is only available when the active subtitle source has been parsed in
`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement.
For full details on layout modes, behavior, and the keyboard shortcut, see the [Subtitle Sidebar](/subtitle-sidebar) page.
`jlptColors` keys are:
| Key | Default | Description |
@@ -1197,6 +1199,7 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
{
"discordPresence": {
"enabled": true,
"presenceStyle": "default",
"updateIntervalMs": 3000,
"debounceMs": 750
}
@@ -1204,23 +1207,30 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
```
| Option | Values | Description |
| ------------------ | --------------- | ---------------------------------------------------------- |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps:
1. Set `discordPresence.enabled` to `true`.
2. Restart SubMiner.
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
3. Restart SubMiner.
SubMiner uses a fixed official activity card style for all users:
#### Presence style presets
- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected)
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
- Large image key/text: `subminer-logo` / `SubMiner`
- Small image key/text: `study` / `Sentence Mining`
- No activity button by default
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
| Preset | Idle details | Small image text | Vibe |
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay |
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
Troubleshooting:

View File

@@ -67,7 +67,7 @@ features:
alt: Subtitle download icon
title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
link: /configuration#jimaku
link: /jimaku-integration
linkText: Jimaku integration
- icon:
src: /assets/tokenization.svg
@@ -223,12 +223,12 @@ const demoAssetVersion = '20260223-2';
}
.workflow-step:hover {
background: hsla(232, 23%, 18%, 0.6);
background: var(--tui-step-hover-bg);
}
.workflow-step:hover .step-number {
color: var(--vp-c-brand-1);
text-shadow: 0 0 12px hsla(267, 83%, 80%, 0.3);
text-shadow: 0 0 12px var(--tui-step-hover-glow);
}
.workflow-connector {

View File

@@ -172,7 +172,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
### Windows Usage Notes
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts.
- If you use the mpv plugin, leave `binary_path` empty unless SubMiner is installed in a non-standard location.
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
@@ -201,6 +201,7 @@ mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner
:::
On Windows, the packaged plugin config is rewritten to `socket_path=\\.\pipe\subminer-socket`.
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
```bash
# Option 1: install from release assets bundle

View File

@@ -131,6 +131,6 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
## Related
- [Configuration Reference](/configuration#jimaku) — full config section
- [Configuration Reference](/configuration#jimaku) — full config options
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) — how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) — additional error guidance

View File

@@ -6,14 +6,17 @@ const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe', () => {
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
expect(docsConfigContents).toContain("hostname: 'https://docs.subminer.moe'");
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
expect(docsThemeContents).toContain("'docs.subminer.moe'");
expect(docsThemeContents).toContain(
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event'",
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
);
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
expect(docsThemeContents).toContain('outboundLinks: true');

View File

@@ -498,6 +498,7 @@
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

@@ -0,0 +1,71 @@
# Subtitle Sidebar
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
The sidebar is opt-in and disabled by default. Enable it under `subtitleSidebar.enabled` in your config.
## How It Works
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
- Clicking any cue seeks mpv to that timestamp.
- The sidebar stays synchronized with the overlay — media transitions and subtitle source changes update both simultaneously.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
## Layout Modes
Two layout modes are available via `subtitleSidebar.layout`:
**`overlay`** (default) — The sidebar floats over mpv as a panel. It does not affect the player window size or position.
**`embedded`** — Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
## Configuration
Enable and configure the sidebar under `subtitleSidebar` in your config file:
```json
{
"subtitleSidebar": {
"enabled": false,
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": false,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
}
}
```
| Option | Type | Default | Description |
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `false` | Enable subtitle sidebar support |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
| `backgroundColor` | string | — | Sidebar shell background color |
| `textColor` | string | — | Default cue text color |
| `fontFamily` | string | — | CSS `font-family` applied to cue text |
| `fontSize` | number | `16` | Base cue font size in CSS pixels |
| `timestampColor` | string | — | Cue timestamp color |
| `activeLineColor` | string | — | Active cue text color |
| `activeLineBackgroundColor` | string | — | Active cue background color |
| `hoverLineBackgroundColor` | string | — | Hovered cue background color |
Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like a solid overlay.
## Keyboard Shortcut
| Key | Action | Config key |
| --- | ----------------------- | ------------------------------ |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.

View File

@@ -21,6 +21,7 @@ Read when: you need internal architecture, workflow, verification, or release gu
- New feature or refactor: [Workflow](./workflow/README.md), then [Architecture](./architecture/README.md)
- Test/build/release work: [Verification](./workflow/verification.md), then [Release Guide](./RELEASING.md)
- Coverage lane selection or LCOV artifact path: [Verification](./workflow/verification.md)
- “What owns this behavior?”: [Domains](./architecture/domains.md)
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
- “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.md)

View File

@@ -3,7 +3,7 @@
# Architecture Map
Status: active
Last verified: 2026-03-13
Last verified: 2026-03-26
Owner: Kyle Yasuda
Read when: runtime ownership, composition boundaries, or layering questions
@@ -24,9 +24,11 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
## Current Shape
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
- `src/main/runtime/composers/` owns larger domain compositions.
## Architecture Intent

View File

@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-03-13
Last verified: 2026-03-26
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -23,17 +23,28 @@ Read when: you need to find the owner module for a behavior or test surface
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
- Stats app: `stats/`
- Public docs site: `docs-site/`
## Shared Contract Entry Points
- Config + app-state contracts: `src/types/config.ts`
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
- Anki-specific contracts: `src/types/anki.ts`
- External integration contracts: `src/types/integrations.ts`
- Runtime-option contracts: `src/types/runtime-options.ts`
- Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics
- Runtime wiring or dependency setup: start in `src/main/`
- Business logic or service behavior: start in `src/core/services/`
- UI interaction or overlay DOM behavior: start in `src/renderer/`
- Command parsing or mpv launch flow: start in `launcher/`
- Shared contract changes: add or edit the narrowest `src/types/<domain>.ts` entrypoint; only touch `src/types.ts` for compatibility exports.
- User-facing docs: `docs-site/`
- Internal process/docs: `docs/`

View File

@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
- [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills
- [Release Guide](../RELEASING.md) - tagged release workflow
## Default Flow

View File

@@ -0,0 +1,32 @@
<!-- read_when: using or modifying repo-local agent plugins -->
# Agent Plugins
Status: active
Last verified: 2026-03-26
Owner: Kyle Yasuda
Read when: packaging or migrating repo-local agent workflow skills into plugins
## SubMiner Workflow Plugin
- Canonical plugin path: `plugins/subminer-workflow/`
- Marketplace catalog: `.agents/plugins/marketplace.json`
- Canonical skill sources:
- `plugins/subminer-workflow/skills/subminer-scrum-master/`
- `plugins/subminer-workflow/skills/subminer-change-verification/`
## Migration Rule
- Plugin-owned skills are the source of truth.
- `.agents/skills/subminer-*` remain only as compatibility shims.
- Existing script entrypoints under `.agents/skills/subminer-change-verification/scripts/` stay as wrappers so historical commands do not break.
## Backlog
- Prefer Backlog.md MCP when the host session exposes it.
- If MCP is unavailable, use repo-local `backlog/` files and record that fallback.
## Verification
- For plugin/docs-only changes, start with `bun run test:docs:kb`.
- Use the plugin-owned verifier when the change crosses from docs into scripts or workflow logic.

View File

@@ -31,8 +31,15 @@ bun run docs:build
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
## Coverage Reporting
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
## Rules
- Capture exact failing command and error when verification breaks.

View File

@@ -227,11 +227,7 @@ test('stats background command launches attached daemon control command with res
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats-daemon-start',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
@@ -257,11 +253,7 @@ test('stats command waits for attached app exit after startup response', async (
const final = await statsCommand;
assert.equal(final, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
@@ -317,11 +309,7 @@ test('stats stop command forwards stop flag to the app', async () => {
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats-daemon-stop',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});

View File

@@ -14,6 +14,7 @@ import {
waitForUnixSocketReady,
} from '../mpv.js';
import type { Args } from '../types.js';
import { nowMs } from '../time.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import {
@@ -116,7 +117,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
child.unref();
},
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => Date.now(),
now: () => nowMs(),
timeoutMs: SETUP_WAIT_TIMEOUT_MS,
pollIntervalMs: SETUP_POLL_INTERVAL_MS,
});
@@ -209,7 +210,11 @@ export async function runPlaybackCommandWithDeps(
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
deps.log(
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
}
await deps.startMpv(
@@ -250,7 +255,11 @@ export async function runPlaybackCommandWithDeps(
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
deps.log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
}
} else if (ready) {
deps.log(

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
@@ -45,8 +46,8 @@ const defaultDeps: StatsCommandDeps = {
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
runAppCommandAttached(appPath, appArgs, logLevel, label),
waitForStatsResponse: async (responsePath, signal) => {
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
while (Date.now() < deadline) {
const deadline = nowMs() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
if (signal?.aborted) {
return {
ok: false,

View File

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
applyInvocationsToArgs,
applyRootOptionsToArgs,
createDefaultArgs,
} from './args-normalizer.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('createDefaultArgs normalizes configured language codes and env thread override', () => {
const originalThreads = process.env.SUBMINER_WHISPER_THREADS;
process.env.SUBMINER_WHISPER_THREADS = '7';
try {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
secondarySubLanguages: ['en', 'ENG', ''],
whisperThreads: 2,
});
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
assert.equal(parsed.whisperThreads, 7);
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
} finally {
if (originalThreads === undefined) {
delete process.env.SUBMINER_WHISPER_THREADS;
} else {
process.env.SUBMINER_WHISPER_THREADS = originalThreads;
}
}
});
test('applyRootOptionsToArgs maps file, directory, and url targets', () => {
withTempDir((dir) => {
const filePath = path.join(dir, 'movie.mkv');
const folderPath = path.join(dir, 'anime');
fs.writeFileSync(filePath, 'x');
fs.mkdirSync(folderPath);
const fileParsed = createDefaultArgs({});
applyRootOptionsToArgs(fileParsed, {}, filePath);
assert.equal(fileParsed.targetKind, 'file');
assert.equal(fileParsed.target, filePath);
const dirParsed = createDefaultArgs({});
applyRootOptionsToArgs(dirParsed, {}, folderPath);
assert.equal(dirParsed.directory, folderPath);
assert.equal(dirParsed.target, '');
assert.equal(dirParsed.targetKind, '');
const urlParsed = createDefaultArgs({});
applyRootOptionsToArgs(urlParsed, {}, 'https://example.test/video');
assert.equal(urlParsed.targetKind, 'url');
assert.equal(urlParsed.target, 'https://example.test/video');
});
});
test('applyRootOptionsToArgs rejects unsupported targets', () => {
const parsed = createDefaultArgs({});
const error = withProcessExitIntercept(() => {
applyRootOptionsToArgs(parsed, {}, '/definitely/missing/subminer-target');
});
assert.equal(error.code, 1);
assert.match(error.message, /exit:1/);
});
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: {
action: 'play',
play: true,
server: 'https://jf.example',
username: 'alice',
password: 'secret',
logLevel: 'debug',
},
configInvocation: {
action: 'show',
logLevel: 'warn',
},
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: false,
texthookerLogLevel: null,
});
assert.equal(parsed.jellyfin, false);
assert.equal(parsed.jellyfinPlay, true);
assert.equal(parsed.jellyfinDiscovery, false);
assert.equal(parsed.jellyfinLogin, false);
assert.equal(parsed.jellyfinLogout, false);
assert.equal(parsed.jellyfinServer, 'https://jf.example');
assert.equal(parsed.jellyfinUsername, 'alice');
assert.equal(parsed.jellyfinPassword, 'secret');
assert.equal(parsed.configShow, true);
assert.equal(parsed.logLevel, 'warn');
});

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseCliPrograms, resolveTopLevelCommand } from './cli-parser-builder.js';
test('resolveTopLevelCommand skips root options and finds the first command', () => {
assert.deepEqual(resolveTopLevelCommand(['--backend', 'macos', 'config', 'show']), {
name: 'config',
index: 2,
});
});
test('resolveTopLevelCommand respects the app alias after root options', () => {
assert.deepEqual(resolveTopLevelCommand(['--log-level', 'debug', 'bin', '--foo']), {
name: 'bin',
index: 2,
});
});
test('parseCliPrograms keeps root options and target when no command is present', () => {
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
assert.equal(result.options.backend, 'x11');
assert.equal(result.rootTarget, '/tmp/movie.mkv');
assert.equal(result.invocations.appInvocation, null);
});
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
const result = parseCliPrograms(
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
'subminer',
);
assert.equal(result.options.backend, 'macos');
assert.deepEqual(result.invocations.appInvocation, {
appArgs: ['--anilist', '--log-level', 'debug'],
});
});

View File

@@ -236,17 +236,12 @@ export function parseCliPrograms(
normalizedAction !== 'rebuild' &&
normalizedAction !== 'backfill'
) {
throw new Error(
'Invalid stats action. Valid values are cleanup, rebuild, or backfill.',
);
throw new Error('Invalid stats action. Valid values are cleanup, rebuild, or backfill.');
}
if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
}
if (
normalizedAction !== 'cleanup' &&
(options.vocab === true || options.lifetime === true)
) {
if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
}
if (normalizedAction === 'cleanup') {

View File

@@ -10,6 +10,7 @@ import type {
JellyfinGroupEntry,
} from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import { nowMs } from './time.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js';
import {
pickLibrary,
@@ -453,9 +454,9 @@ async function runAppJellyfinCommand(
}
return retriedAfterStart ? 12000 : 4000;
})();
const settleDeadline = Date.now() + settleWindowMs;
const settleDeadline = nowMs() + settleWindowMs;
const settleOffset = attempt.logOffset;
while (Date.now() < settleDeadline) {
while (nowMs() < settleDeadline) {
await sleep(100);
const settledOutput = readLogAppendedSince(settleOffset);
if (!settledOutput.trim()) {
@@ -489,8 +490,8 @@ async function requestJellyfinPreviewAuthFromApp(
return null;
}
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const deadline = nowMs() + 4000;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
const raw = fs.readFileSync(responsePath, 'utf8');

View File

@@ -14,12 +14,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
assert.equal(
path.normalize(resolved),
path.normalize(
path.join(
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`mpv-${today}.log`,
),
path.join('C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', `mpv-${today}.log`),
),
);
});
@@ -33,12 +28,6 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => {
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`launcher-${today}.log`,
),
path.join('/home/tester', '.config', 'SubMiner', 'logs', `launcher-${today}.log`),
);
});

View File

@@ -36,6 +36,8 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
}
const LAUNCHER_RUN_TIMEOUT_MS = 30000;
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
const result = spawnSync(
process.execPath,
@@ -43,6 +45,7 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
{
env,
encoding: 'utf8',
timeout: LAUNCHER_RUN_TIMEOUT_MS,
},
);
return {
@@ -269,10 +272,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
};
const result = runLauncher(
['--args', '--pause=yes --title="movie night"', videoPath],
env,
);
const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const argsFile = fs.readFileSync(mpvArgsPath, 'utf8');
@@ -355,10 +355,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/--script-opts=.*subminer-log_level=debug/,
);
assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/);
});
});

View File

@@ -427,7 +427,10 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
}
}
function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void {
function withAccessSyncStub(
isExecutablePath: (filePath: string) => boolean,
run: () => void,
): void {
const originalAccessSync = fs.accessSync;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -468,10 +471,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
try {
os.homedir = () => baseDir;
withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => {
withAccessSyncStub(
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
() => {
const result = findAppBinary('/some/other/path/subminer');
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
});
},
);
});
} finally {
os.homedir = originalHomedir;
@@ -492,11 +498,14 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === wrapperPath, () => {
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
// selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
assert.equal(result, wrapperPath);
});
},
);
});
} finally {
os.homedir = originalHomedir;

View File

@@ -7,6 +7,7 @@ import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { nowMs } from './time.js';
import {
commandExists,
getPathEnv,
@@ -47,7 +48,11 @@ export function parseMpvArgString(input: string): string[] {
let inDoubleQuote = false;
let escaping = false;
const canEscape = (nextChar: string | undefined): boolean =>
nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar);
nextChar === undefined ||
nextChar === '"' ||
nextChar === "'" ||
nextChar === '\\' ||
/\s/.test(nextChar);
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i] || '';
@@ -196,8 +201,8 @@ async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
return;
}
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
const deadline = nowMs() + 1500;
while (nowMs() < deadline) {
if (!isProcessAlive(pid)) {
clearTrackedDetachedMpvPid();
return;
@@ -340,7 +345,7 @@ export function sendMpvCommandWithResponse(
timeoutMs = 5000,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const requestId = Date.now() + Math.floor(Math.random() * 1000);
const requestId = nowMs() + Math.floor(Math.random() * 1000);
const socket = net.createConnection(socketPath);
let buffer = '';
@@ -598,7 +603,9 @@ export async function startMpv(
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts =
targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true
targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const scriptOpts = buildSubminerScriptOpts(
@@ -1064,7 +1071,9 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
mpvArgs.push('--idle=yes');
mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
@@ -1109,8 +1118,8 @@ export async function waitForUnixSocketReady(
socketPath: string,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const deadline = nowMs() + timeoutMs;
while (nowMs() < deadline) {
try {
if (fs.existsSync(socketPath)) {
const ready = await canConnectUnixSocket(socketPath);

8
launcher/time.ts Normal file
View File

@@ -0,0 +1,8 @@
export function nowMs(): number {
const perf = globalThis.performance;
if (perf) {
return Math.floor(perf.timeOrigin + perf.now());
}
return Number(process.hrtime.bigint() / 1000000n);
}

View File

@@ -4,6 +4,7 @@ import os from 'node:os';
import { spawn } from 'node:child_process';
import type { LogLevel, CommandExecOptions, CommandExecResult } from './types.js';
import { log } from './log.js';
import { nowMs } from './time.js';
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -198,7 +199,7 @@ export function normalizeBasename(value: string, fallback: string): string {
if (safe) return safe;
const fallbackSafe = sanitizeToken(fallback);
if (fallbackSafe) return fallbackSafe;
return `${Date.now()}`;
return `${nowMs()}`;
}
export function normalizeLangCode(value: string): string {

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.9.3",
"version": "0.10.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -42,9 +42,9 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
@@ -52,6 +52,8 @@
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
"test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist",
"test:src": "bun scripts/run-test-lane.mjs bun-src-full",
"test:coverage:src": "bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src",
"test:coverage:subtitle:src": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage/test-subtitle src/core/services/subsync.test.ts src/subsync/utils.test.ts",
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
@@ -63,7 +65,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
@@ -98,10 +100,9 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@hono/node-server": "^1.19.11",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"libsql": "^0.5.22",

View File

@@ -153,6 +153,9 @@ function M.create(ctx)
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
if state.suppress_ready_overlay_restore then
return
end
if state.overlay_running and resolve_visible_overlay_startup() then
run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
@@ -287,6 +290,9 @@ function M.create(ctx)
local function start_overlay(overrides)
overrides = overrides or {}
if overrides.auto_start_trigger == true then
state.suppress_ready_overlay_restore = false
end
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
@@ -433,6 +439,7 @@ function M.create(ctx)
subminer_log("error", "binary", "SubMiner binary not found")
return
end
state.suppress_ready_overlay_restore = true
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
if ok then
@@ -456,8 +463,9 @@ function M.create(ctx)
show_osd("Error: binary not found")
return
end
state.suppress_ready_overlay_restore = true
run_control_command_async("toggle", nil, function(ok)
run_control_command_async("toggle-visible-overlay", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Toggle command failed")
show_osd("Toggle failed")

View File

@@ -32,6 +32,7 @@ function M.new()
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
}
end

View File

@@ -0,0 +1,30 @@
{
"name": "subminer-workflow",
"version": "0.1.0",
"description": "Repo-local SubMiner agent workflow plugin for backlog-first orchestration and change verification.",
"author": {
"name": "Kyle Yasuda",
"email": "suda@sudacode.com",
"url": "https://github.com/sudacode"
},
"homepage": "https://github.com/sudacode/SubMiner/tree/main/plugins/subminer-workflow",
"repository": "https://github.com/sudacode/SubMiner",
"license": "GPL-3.0-or-later",
"keywords": ["subminer", "workflow", "backlog", "verification", "skills"],
"skills": "./skills/",
"interface": {
"displayName": "SubMiner Workflow",
"shortDescription": "Backlog-first SubMiner orchestration and verification.",
"longDescription": "Canonical repo-local plugin for SubMiner agent workflow packaging. Owns the scrum-master and change-verification skills plus helper scripts used to plan, verify, and validate changes reproducibly inside this repo.",
"developerName": "Kyle Yasuda",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"websiteURL": "https://github.com/sudacode/SubMiner",
"defaultPrompt": [
"Use SubMiner workflow to plan and ship a feature.",
"Verify a SubMiner change with the plugin-owned verifier.",
"Run backlog-first intake for this SubMiner task."
],
"brandColor": "#2F6B4F"
}
}

View File

@@ -0,0 +1,49 @@
<!-- read_when: migrating or using the repo-local SubMiner workflow plugin -->
# SubMiner Workflow Plugin
Status: active
Last verified: 2026-03-26
Owner: Kyle Yasuda
Read when: using or updating the repo-local plugin that owns SubMiner agent workflow skills
This plugin is the canonical source of truth for the SubMiner agent workflow packaging.
## Contents
- `skills/subminer-scrum-master/`
- backlog-first intake, planning, dispatch, and handoff workflow
- `skills/subminer-change-verification/`
- cheap-first verification workflow plus helper scripts
## Backlog MCP
- This plugin assumes Backlog.md MCP is available in the host environment when the client exposes it.
- Canonical backlog behavior remains:
- read `backlog://workflow/overview` when resources are available
- otherwise use the matching backlog tool overview
- If backlog MCP is unavailable in the current session, fall back to direct repo-local `backlog/` edits and record that blocker in the task or handoff.
## Compatibility
- `.agents/skills/subminer-scrum-master/` is a compatibility shim that redirects to the plugin-owned skill.
- `.agents/skills/subminer-change-verification/` is a compatibility shim.
- `.agents/skills/subminer-change-verification/scripts/*.sh` remain as wrapper entrypoints so existing docs, backlog tasks, and shell history keep working.
## Verification
For plugin/doc/shim changes, prefer:
```bash
bun run test:docs:kb
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane core \
plugins/subminer-workflow \
.agents/skills/subminer-scrum-master/SKILL.md \
.agents/skills/subminer-change-verification/SKILL.md \
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
.agents/plugins/marketplace.json \
docs/workflow/README.md \
docs/workflow/agent-plugins.md \
backlog/tasks/task-240\ -\ Migrate-SubMiner-agent-skills-into-a-repo-local-plugin-workflow.md
```

View File

@@ -0,0 +1,141 @@
---
name: 'subminer-change-verification'
description: 'Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation.'
---
# SubMiner Change Verification
Canonical source: this plugin path.
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
## Scripts
- `scripts/classify_subminer_diff.sh`
- Emits suggested lanes and flags from explicit paths or current git changes.
- `scripts/verify_subminer_change.sh`
- Runs selected lanes, captures artifacts, and writes a compact summary.
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
```bash
export SUBMINER_VERIFY_SKILL="<path-to-plugin-skill>"
```
## Default workflow
1. Inspect the changed files or user-requested area.
2. Run the classifier unless you already know the right lane.
3. Run the verifier with the cheapest sufficient lane set.
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
6. Return:
- verification summary
- exact commands run
- artifact paths
- skipped lanes and blockers
## Quick start
Plugin-source quick start:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Installed-skill quick start:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
```
Compatibility entrypoint:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Classify explicit files:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
launcher/main.ts \
plugin/subminer/lifecycle.lua \
src/main/runtime/mpv-client-runtime-service.ts
```
Run automatic lane selection:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Installed-skill form:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
```
Compatibility entrypoint:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Run targeted lanes:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--lane launcher-plugin \
--lane runtime-compat
```
Dry-run to inspect planned commands and artifact layout:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--dry-run \
launcher/main.ts \
src/main.ts
```
## Lane guidance
- `docs`
- For `docs-site/`, `docs/`, and doc-only edits.
- `config`
- For `src/config/` and config-template-sensitive edits.
- `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin`
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
- `runtime-compat`
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
- `real-runtime`
- Only after deliberate escalation.
## Real Runtime Escalation
Escalate only when the change claim depends on actual runtime behavior, for example:
- overlay appears, hides, or tracks a real mpv window
- mpv launch flags or pause-until-ready behavior
- plugin/socket/auto-start handshake under a real player
- macOS/window-tracker/focus-sensitive behavior
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
## Artifact contract
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
- `summary.json`
- `summary.txt`
- `classification.txt`
- `env.txt`
- `lanes.txt`
- `steps.tsv`
- `steps/*.stdout.log`
- `steps/*.stderr.log`
On failure, quote the exact failing command and point at the artifact directory.

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: classify_subminer_diff.sh [path ...]
Emit suggested verification lanes for explicit paths or current local git changes.
Output format:
lane:<name>
flag:<name>
reason:<text>
EOF
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
add_lane() {
local lane=$1
if ! has_item "$lane" "${LANES[@]:-}"; then
LANES+=("$lane")
fi
}
add_flag() {
local flag=$1
if ! has_item "$flag" "${FLAGS[@]:-}"; then
FLAGS+=("$flag")
fi
}
add_reason() {
REASONS+=("$1")
}
collect_git_paths() {
local top_level
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
return 0
fi
(
cd "$top_level"
if git rev-parse --verify HEAD >/dev/null 2>&1; then
git diff --name-only --relative HEAD --
git diff --name-only --relative --cached --
else
git diff --name-only --relative --
git diff --name-only --relative --cached --
fi
git ls-files --others --exclude-standard
) | awk 'NF' | sort -u
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
declare -a PATHS=()
declare -a LANES=()
declare -a FLAGS=()
declare -a REASONS=()
if [[ $# -gt 0 ]]; then
while [[ $# -gt 0 ]]; do
PATHS+=("$1")
shift
done
else
while IFS= read -r line; do
[[ -n "$line" ]] && PATHS+=("$line")
done < <(collect_git_paths)
fi
if [[ ${#PATHS[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no changed paths detected -> default to core"
fi
for path in "${PATHS[@]}"; do
specialized=0
case "$path" in
docs-site/*|docs/*|changes/*|README.md)
add_lane "docs"
add_reason "$path -> docs"
specialized=1
;;
esac
case "$path" in
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
add_lane "config"
add_reason "$path -> config"
specialized=1
;;
esac
case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin"
add_reason "$path -> launcher-plugin"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
case "$path" in
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
add_lane "runtime-compat"
add_reason "$path -> runtime-compat"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
if [[ "$specialized" == "0" ]]; then
case "$path" in
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
add_lane "core"
add_reason "$path -> core"
;;
esac
fi
case "$path" in
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
add_flag "broad-impact"
add_reason "$path -> broad-impact"
;;
esac
done
if [[ ${#LANES[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no lane-specific matches -> default to core"
fi
for lane in "${LANES[@]}"; do
printf 'lane:%s\n' "$lane"
done
for flag in "${FLAGS[@]}"; do
printf 'flag:%s\n' "$flag"
done
for reason in "${REASONS[@]}"; do
printf 'reason:%s\n' "$reason"
done

View File

@@ -0,0 +1,524 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: verify_subminer_change.sh [options] [path ...]
Options:
--lane <name> Force a verification lane. Repeatable.
--artifact-dir <dir> Use an explicit artifact directory.
--allow-real-runtime Allow explicit real-runtime execution.
--allow-real-gui Deprecated alias for --allow-real-runtime.
--dry-run Record planned steps without executing commands.
--help Show this help text.
If no lanes are supplied, the script classifies the provided paths. If no paths are
provided, it classifies the current local git changes.
Authoritative real-runtime verification should be requested with explicit path
arguments instead of relying on inferred local git changes.
EOF
}
timestamp() {
date +%Y%m%d-%H%M%S
}
timestamp_iso() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
generate_session_id() {
local tmp_dir
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
basename "$tmp_dir"
rmdir "$tmp_dir"
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
normalize_lane_name() {
case "$1" in
real-gui)
printf '%s' "real-runtime"
;;
*)
printf '%s' "$1"
;;
esac
}
add_lane() {
local lane
lane=$(normalize_lane_name "$1")
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
SELECTED_LANES+=("$lane")
fi
}
add_blocker() {
BLOCKERS+=("$1")
BLOCKED=1
}
validate_artifact_dir() {
local candidate=$1
if [[ ! "$candidate" =~ ^[A-Za-z0-9._/@:+-]+$ ]]; then
echo "Invalid characters in --artifact-dir path" >&2
exit 2
fi
}
append_step_record() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
}
record_env() {
{
printf 'repo_root=%s\n' "$REPO_ROOT"
printf 'session_id=%s\n' "$SESSION_ID"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
printf 'dry_run=%s\n' "$DRY_RUN"
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
printf 'session_home=%s\n' "$SESSION_HOME"
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
printf 'pwd=%s\n' "$(pwd)"
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
git status --short 2>/dev/null || true
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf 'requested_paths=\n'
printf ' %s\n' "${PATH_ARGS[@]}"
fi
} >"$ARTIFACT_DIR/env.txt"
}
run_step() {
local lane=$1
local name=$2
local command=$3
local note=${4:-}
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local step_slug="${lane_slug}--${slug}"
local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
local status exit_code
COMMANDS_RUN+=("$command")
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$command" >"$stdout_path"
: >"$stderr_path"
status="dry-run"
exit_code=0
else
if HOME="$SESSION_HOME" \
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
status="passed"
exit_code=0
EXECUTED_REAL_STEPS=1
else
exit_code=$?
status="failed"
FAILED=1
fi
fi
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
if [[ "$status" == "failed" ]]; then
FAILURE_STEP="$name"
FAILURE_COMMAND="$command"
FAILURE_STDOUT="$stdout_rel"
FAILURE_STDERR="$stderr_rel"
return "$exit_code"
fi
}
record_nonpassing_step() {
local lane=$1
local name=$2
local status=$3
local note=$4
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local step_slug="${lane_slug}--${slug}"
local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
: >"$ARTIFACT_DIR/$stderr_rel"
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
}
record_skipped_step() {
record_nonpassing_step "$1" "$2" "skipped" "$3"
}
record_blocked_step() {
add_blocker "$3"
record_nonpassing_step "$1" "$2" "blocked" "$3"
}
record_failed_step() {
FAILED=1
FAILURE_STEP=$2
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
local step_slug=${2//[^a-zA-Z0-9_-]/-}
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
add_blocker "$3"
record_nonpassing_step "$1" "$2" "failed" "$3"
}
find_real_runtime_helper() {
local candidate
for candidate in \
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
acquire_real_runtime_lease() {
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
local lease_dir="$lease_root/exclusive-real-runtime"
mkdir -p "$lease_root"
if mkdir "$lease_dir" 2>/dev/null; then
REAL_RUNTIME_LEASE_DIR="$lease_dir"
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
return 0
fi
local owner=""
if [[ -f "$lease_dir/session_id" ]]; then
owner=$(cat "$lease_dir/session_id")
fi
REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
return 1
}
release_real_runtime_lease() {
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
local owner
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
if [[ "$owner" != "$SESSION_ID" ]]; then
return 0
fi
fi
rm -rf "$REAL_RUNTIME_LEASE_DIR"
fi
}
compute_final_status() {
if [[ "$FAILED" == "1" ]]; then
FINAL_STATUS="failed"
elif [[ "$BLOCKED" == "1" ]]; then
FINAL_STATUS="blocked"
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
FINAL_STATUS="passed"
else
FINAL_STATUS="skipped"
fi
}
write_summary_files() {
local lane_lines
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
SESSION_ID_ENV="$SESSION_ID" \
FINAL_STATUS_ENV="$FINAL_STATUS" \
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
SESSION_HOME_ENV="$SESSION_HOME" \
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
STARTED_AT_ENV="$STARTED_AT" \
FINISHED_AT_ENV="$FINISHED_AT" \
FAILED_ENV="$FAILED" \
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
bun -e '
const fs = require("fs");
const path = require("path");
const lines = fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "steps.tsv"), "utf8")
.trim()
.split("\n")
.filter(Boolean)
.slice(1)
.map((line) => {
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
return { lane, name, status, exitCode: Number(exitCode), command, stdout, stderr, note };
});
const payload = {
sessionId: process.env.SESSION_ID_ENV,
startedAt: process.env.STARTED_AT_ENV,
finishedAt: process.env.FINISHED_AT_ENV,
status: process.env.FINAL_STATUS_ENV,
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV,
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
sessionHome: process.env.SESSION_HOME_ENV,
sessionXdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV,
sessionMpvDir: process.env.SESSION_MPV_DIR_ENV,
sessionLogsDir: process.env.SESSION_LOGS_DIR_ENV,
sessionMpvLog: process.env.SESSION_MPV_LOG_ENV,
failed: process.env.FAILED_ENV === "1",
failure: process.env.FAILURE_COMMAND_ENV
? {
command: process.env.FAILURE_COMMAND_ENV,
stdout: process.env.FAILURE_STDOUT_ENV,
stderr: process.env.FAILURE_STDERR_ENV,
}
: null,
blockers: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "blockers.txt"), "utf8")
.split("\n")
.filter(Boolean),
lanes: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "lanes.txt"), "utf8")
.split("\n")
.filter(Boolean),
requestedPaths: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "requested-paths.txt"), "utf8")
.split("\n")
.filter(Boolean),
steps: lines,
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR_ENV, "summary.json"),
JSON.stringify(payload, null, 2) + "\n",
);
const summaryLines = [
`status: ${payload.status}`,
`session: ${payload.sessionId}`,
`artifacts: ${process.env.ARTIFACT_DIR_ENV}`,
`lanes: ${payload.lanes.join(", ") || "(none)"}`,
];
if (payload.requestedPaths.length > 0) {
summaryLines.push("requested paths:");
for (const entry of payload.requestedPaths) {
summaryLines.push(`- ${entry}`);
}
}
if (payload.failure) {
summaryLines.push(`failure command: ${payload.failure.command}`);
summaryLines.push(`failure stdout: ${payload.failure.stdout}`);
summaryLines.push(`failure stderr: ${payload.failure.stderr}`);
}
if (payload.blockers.length > 0) {
summaryLines.push("blockers:");
for (const blocker of payload.blockers) {
summaryLines.push(`- ${blocker}`);
}
}
summaryLines.push("steps:");
for (const step of payload.steps) {
summaryLines.push(`- ${step.lane}/${step.name}: ${step.status}`);
}
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR_ENV, "summary.txt"),
summaryLines.join("\n") + "\n",
);
'
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
declare -a PATH_ARGS=()
declare -a SELECTED_LANES=()
declare -a COMMANDS_RUN=()
declare -a BLOCKERS=()
ALLOW_REAL_RUNTIME=0
DRY_RUN=0
FAILED=0
BLOCKED=0
EXECUTED_REAL_STEPS=0
FAILURE_STEP=""
FAILURE_COMMAND=""
FAILURE_STDOUT=""
FAILURE_STDERR=""
REAL_RUNTIME_LEASE_DIR=""
REAL_RUNTIME_LEASE_ERROR=""
PATH_SELECTION_MODE="auto"
trap 'release_real_runtime_lease' EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--lane)
shift
[[ $# -gt 0 ]] || {
echo "Missing value for --lane" >&2
exit 2
}
add_lane "$1"
PATH_SELECTION_MODE="explicit-lanes"
;;
--artifact-dir)
shift
[[ $# -gt 0 ]] || {
echo "Missing value for --artifact-dir" >&2
exit 2
}
ARTIFACT_DIR=$1
;;
--allow-real-runtime|--allow-real-gui)
ALLOW_REAL_RUNTIME=1
;;
--dry-run)
DRY_RUN=1
;;
--help|-h)
usage
exit 0
;;
*)
PATH_ARGS+=("$1")
;;
esac
shift || true
done
if [[ -z "${ARTIFACT_DIR:-}" ]]; then
SESSION_ID=$(generate_session_id)
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
else
validate_artifact_dir "$ARTIFACT_DIR"
SESSION_ID=$(basename "$ARTIFACT_DIR")
fi
mkdir -p "$ARTIFACT_DIR/steps"
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
printf 'lane\tstep\tstatus\texit_code\tcommand\tstdout\tstderr\tnote\n' >"$STEPS_TSV"
STARTED_AT=$(timestamp_iso)
SESSION_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/home"
SESSION_XDG_CONFIG_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/xdg-config"
SESSION_MPV_DIR="$SESSION_XDG_CONFIG_HOME/mpv"
SESSION_LOGS_DIR="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/logs"
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
mkdir -p "$SESSION_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
CLASSIFIER_OUTPUT="$ARTIFACT_DIR/classification.txt"
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
PATH_SELECTION_MODE="explicit-paths"
fi
if "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}" >"$CLASSIFIER_OUTPUT"; then
while IFS= read -r line; do
case "$line" in
lane:*)
add_lane "${line#lane:}"
;;
esac
done <"$CLASSIFIER_OUTPUT"
else
record_failed_step "meta" "classify" "classification failed"
fi
else
: >"$CLASSIFIER_OUTPUT"
fi
record_env
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
add_lane "core"
fi
for lane in "${SELECTED_LANES[@]}"; do
case "$lane" in
docs)
run_step "$lane" "docs-kb" "bun run test:docs:kb" || break
;;
config)
run_step "$lane" "config" "bun run test:config" || break
;;
core)
run_step "$lane" "typecheck" "bun run typecheck" || break
run_step "$lane" "fast-tests" "bun run test:fast" || break
;;
launcher-plugin)
run_step "$lane" "launcher" "bun run test:launcher" || break
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
;;
runtime-compat)
run_step "$lane" "runtime-compat" "bun run test:runtime:compat" || break
;;
real-runtime)
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
record_blocked_step "$lane" "real-runtime" "real-runtime requested without --allow-real-runtime"
continue
fi
if ! acquire_real_runtime_lease; then
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
continue
fi
helper=$(find_real_runtime_helper || true)
if [[ -z "${helper:-}" ]]; then
record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR"
continue
fi
run_step "$lane" "real-runtime" "\"$helper\" \"$SESSION_ID\" \"$ARTIFACT_DIR\"" || break
;;
*)
record_blocked_step "$lane" "unknown-lane" "unknown lane: $lane"
;;
esac
done
release_real_runtime_lease
FINISHED_AT=$(timestamp_iso)
compute_final_status
write_summary_files
printf 'summary:%s\n' "$ARTIFACT_DIR/summary.txt"
cat "$ARTIFACT_DIR/summary.txt"

View File

@@ -0,0 +1,162 @@
---
name: 'subminer-scrum-master'
description: 'Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff.'
---
# SubMiner Scrum Master
Canonical source: this plugin path.
Own workflow, not code by default.
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
## Core Rules
1. Decide first whether backlog tracking is warranted.
2. If backlog is needed, search first. Update existing work when it clearly matches.
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
4. Record a plan before dispatching coding work.
5. Use parent + subtasks for multi-part work when backlog is used.
6. Dispatch conservatively. Parallelize only disjoint write scopes.
7. Require verification before handoff, typically via `subminer-change-verification`.
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
## Backlog Workflow
Preferred order:
1. Read `backlog://workflow/overview` when MCP resources are available.
2. If resources are unavailable, use the corresponding backlog tool overview.
3. If backlog MCP is unavailable in the session, work directly in repo-local `backlog/` files and record that constraint explicitly.
## Backlog Decision
Skip backlog when the request is:
- question only
- obvious mechanical edit
- tiny isolated change with no real planning
Use backlog when the work:
- needs planning or scope decisions
- spans multiple phases or subsystems
- is likely to need subagent dispatch
- should remain traceable for handoff/resume
If backlog is used:
- search existing tasks first
- create/update a standalone task for one focused deliverable
- create/update a parent task plus subtasks for multi-part work
- record the implementation plan in the task before implementation begins
## Intake Workflow
1. Parse the request.
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
2. Decide whether backlog is needed.
3. If backlog is needed:
- search first
- update existing task if clearly relevant
- otherwise create the right structure
- write the implementation plan before dispatch
4. If backlog is skipped:
- write a short working plan in-thread
- proceed without fake ticketing
5. Choose execution mode:
- no subagents for trivial work
- one worker for focused work
- parallel workers only for disjoint scopes
6. Run verification before handoff.
## Dispatch Rules
The scrum master orchestrates. Workers implement.
- Do not become the default implementer unless delegation is unnecessary.
- Do not parallelize overlapping files or tightly coupled runtime work.
- Give every worker explicit ownership of files/modules.
- Tell every worker other agents may be active and they must not revert unrelated edits.
- Require each worker to report:
- changed files
- tests run
- blockers
Use worker agents for implementation and explorer agents only for bounded codebase questions.
## Verification
Every nontrivial code task gets verification.
Preferred flow:
1. use `subminer-change-verification`
2. start with the cheapest sufficient lane
3. escalate only when needed
4. if worker verification is sufficient, accept it or run one final consolidating pass
Never hand off nontrivial work without stating what was verified and what was skipped.
## Pre-Handoff Policy Checks
Before handoff, always ask and answer both questions explicitly:
1. Docs update required?
2. Changelog fragment required?
Rules:
- Do not assume silence implies "no."
- If the answer is yes, complete the update or report the blocker.
- Include final yes/no answers in the handoff summary even when both answers are "no."
## Failure / Scope Handling
- If a worker hits ambiguity, pause and ask the user.
- If verification fails, either:
- send the worker back with exact failure context, or
- fix it directly if it is tiny and clearly in scope
- If new scope appears, revisit backlog structure before silently expanding work.
## Representative Flows
### Trivial no-ticket work
- decide backlog is unnecessary
- keep a short plan
- implement directly or with one worker if helpful
- run targeted verification
- report outcome concisely
### Single-task implementation
- search/create/update one task
- record plan
- dispatch one worker
- integrate
- verify
- update task and report outcome
### Parent + subtasks execution
- search/create/update parent task
- create subtasks for distinct deliverables/phases
- record sequencing in the plan
- dispatch workers only where scopes are disjoint
- integrate
- run consolidated verification
- update task state and report outcome
## Output Expectations
At the end, report:
- whether backlog was used and what changed
- which workers were dispatched and what they owned
- what verification ran
- explicit answers to:
- docs update required?
- changelog fragment required?
- blockers, skips, and risks

View File

@@ -111,7 +111,11 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'),
'utf8',
);
try {
const result = writeChangelogArtifacts({
@@ -125,7 +129,10 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.equal(changelog, existingChangelog);
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8');
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });

View File

@@ -354,11 +354,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Removed ${fragment.path}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
existingReleaseSection,
options?.deps,
);
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps);
log(`Generated ${releaseNotesPath}`);
return {

View File

@@ -55,10 +55,7 @@ exit 1
`,
);
const result = spawnSync(
'bash',
['scripts/patch-modernz.sh', '--target', target],
{
const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target', target], {
cwd: process.cwd(),
encoding: 'utf8',
env: {
@@ -66,8 +63,7 @@ exit 1
HOME: path.join(root, 'home'),
PATH: `${binDir}:${process.env.PATH || ''}`,
},
},
);
});
assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(result.stderr, /failed to apply patch to/);

View File

@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import test from 'node:test';
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([
[
'SF:src/example.ts',
'FN:10,alpha',
'FNDA:1,alpha',
'DA:10,1',
'DA:11,0',
'BRDA:10,0,0,1',
'BRDA:10,0,1,-',
'end_of_record',
'',
].join('\n'),
[
'SF:src/example.ts',
'FN:10,alpha',
'FN:20,beta',
'FNDA:2,alpha',
'FNDA:1,beta',
'DA:10,2',
'DA:11,1',
'DA:20,1',
'BRDA:10,0,0,0',
'BRDA:10,0,1,1',
'end_of_record',
'',
].join('\n'),
]);
assert.match(merged, /SF:src\/example\.ts/);
assert.match(merged, /FN:10,alpha/);
assert.match(merged, /FN:20,beta/);
assert.match(merged, /FNDA:3,alpha/);
assert.match(merged, /FNDA:1,beta/);
assert.match(merged, /FNF:2/);
assert.match(merged, /FNH:2/);
assert.match(merged, /DA:10,3/);
assert.match(merged, /DA:11,1/);
assert.match(merged, /DA:20,1/);
assert.match(merged, /LF:3/);
assert.match(merged, /LH:3/);
assert.match(merged, /BRDA:10,0,0,1/);
assert.match(merged, /BRDA:10,0,1,1/);
assert.match(merged, /BRF:2/);
assert.match(merged, /BRH:2/);
});
test('mergeLcovReports keeps distinct source files as separate records', () => {
const merged = mergeLcovReports([
['SF:src/a.ts', 'DA:1,1', 'end_of_record', ''].join('\n'),
['SF:src/b.ts', 'DA:2,1', 'end_of_record', ''].join('\n'),
]);
assert.match(merged, /SF:src\/a\.ts[\s\S]*end_of_record/);
assert.match(merged, /SF:src\/b\.ts[\s\S]*end_of_record/);
});
test('resolveCoverageDir keeps coverage output inside the repository', () => {
const repoRoot = resolve('/tmp', 'subminer-repo-root');
assert.equal(resolveCoverageDir(repoRoot, []), resolve(repoRoot, 'coverage'));
assert.equal(
resolveCoverageDir(repoRoot, ['--coverage-dir', 'coverage/test-src']),
resolve(repoRoot, 'coverage/test-src'),
);
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});

View File

@@ -0,0 +1,311 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { isAbsolute, join, relative, resolve } from 'node:path';
type LaneConfig = {
roots: string[];
include: string[];
exclude: Set<string>;
};
type LcovRecord = {
sourceFile: string;
functions: Map<string, number>;
functionHits: Map<string, number>;
lines: Map<number, number>;
branches: Map<string, { line: number; block: string; branch: string; hits: number | null }>;
};
const repoRoot = resolve(__dirname, '..');
const lanes: Record<string, LaneConfig> = {
'bun-src-full': {
roots: ['src'],
include: ['.test.ts', '.type-test.ts'],
exclude: new Set([
'src/core/services/anki-jimaku-ipc.test.ts',
'src/core/services/ipc.test.ts',
'src/core/services/overlay-manager.test.ts',
'src/main/config-validation.test.ts',
'src/main/runtime/registry.test.ts',
'src/main/runtime/startup-config.test.ts',
]),
},
'bun-launcher-unit': {
roots: ['launcher'],
include: ['.test.ts'],
exclude: new Set(['launcher/smoke.e2e.test.ts']),
},
};
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] {
const out: string[] = [];
const visit = (currentDir: string) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = resolve(currentDir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
if (excludeSet.has(relPath)) continue;
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
out.push(relPath);
}
}
};
visit(resolve(repoRoot, rootDir));
out.sort();
return out;
}
function getLaneFiles(laneName: string): string[] {
const lane = lanes[laneName];
if (!lane) {
throw new Error(`Unknown coverage lane: ${laneName}`);
}
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
if (files.length === 0) {
throw new Error(`No test files found for coverage lane: ${laneName}`);
}
return files;
}
function parseCoverageDirArg(argv: string[]): string {
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--coverage-dir') {
const next = argv[index + 1];
if (typeof next !== 'string') {
throw new Error('Missing value for --coverage-dir');
}
return next;
}
}
return 'coverage';
}
export function resolveCoverageDir(repoRootDir: string, argv: string[]): string {
const candidate = resolve(repoRootDir, parseCoverageDirArg(argv));
const rel = relative(repoRootDir, candidate);
if (isAbsolute(rel) || rel.startsWith('..')) {
throw new Error(`--coverage-dir must be within repository: ${candidate}`);
}
return candidate;
}
function parseLcovReport(report: string): LcovRecord[] {
const records: LcovRecord[] = [];
let current: LcovRecord | null = null;
const ensureCurrent = (): LcovRecord => {
if (!current) {
throw new Error('Malformed lcov report: record data before SF');
}
return current;
};
for (const rawLine of report.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('TN:')) {
continue;
}
if (line.startsWith('SF:')) {
current = {
sourceFile: line.slice(3),
functions: new Map(),
functionHits: new Map(),
lines: new Map(),
branches: new Map(),
};
continue;
}
if (line === 'end_of_record') {
if (current) {
records.push(current);
current = null;
}
continue;
}
if (line.startsWith('FN:')) {
const [lineNumber, ...nameParts] = line.slice(3).split(',');
ensureCurrent().functions.set(nameParts.join(','), Number(lineNumber));
continue;
}
if (line.startsWith('FNDA:')) {
const [hits, ...nameParts] = line.slice(5).split(',');
ensureCurrent().functionHits.set(nameParts.join(','), Number(hits));
continue;
}
if (line.startsWith('DA:')) {
const [lineNumber, hits] = line.slice(3).split(',');
ensureCurrent().lines.set(Number(lineNumber), Number(hits));
continue;
}
if (line.startsWith('BRDA:')) {
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
continue;
}
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
line: Number(lineNumber),
block,
branch,
hits: hits === '-' ? null : Number(hits),
});
}
}
if (current) {
records.push(current);
}
return records;
}
export function mergeLcovReports(reports: string[]): string {
const merged = new Map<string, LcovRecord>();
for (const report of reports) {
for (const record of parseLcovReport(report)) {
let target = merged.get(record.sourceFile);
if (!target) {
target = {
sourceFile: record.sourceFile,
functions: new Map(),
functionHits: new Map(),
lines: new Map(),
branches: new Map(),
};
merged.set(record.sourceFile, target);
}
for (const [name, line] of record.functions) {
if (!target.functions.has(name)) {
target.functions.set(name, line);
}
}
for (const [name, hits] of record.functionHits) {
target.functionHits.set(name, (target.functionHits.get(name) ?? 0) + hits);
}
for (const [lineNumber, hits] of record.lines) {
target.lines.set(lineNumber, (target.lines.get(lineNumber) ?? 0) + hits);
}
for (const [branchKey, branchRecord] of record.branches) {
const existing = target.branches.get(branchKey);
if (!existing) {
target.branches.set(branchKey, { ...branchRecord });
continue;
}
if (branchRecord.hits === null) {
continue;
}
existing.hits = (existing.hits ?? 0) + branchRecord.hits;
}
}
}
const chunks: string[] = [];
for (const sourceFile of [...merged.keys()].sort()) {
const record = merged.get(sourceFile)!;
chunks.push(`SF:${record.sourceFile}`);
const functions = [...record.functions.entries()].sort((a, b) =>
a[1] === b[1] ? a[0].localeCompare(b[0]) : a[1] - b[1],
);
for (const [name, line] of functions) {
chunks.push(`FN:${line},${name}`);
}
for (const [name] of functions) {
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
}
chunks.push(`FNF:${functions.length}`);
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
const branches = [...record.branches.values()].sort((a, b) =>
a.line === b.line
? a.block === b.block
? a.branch.localeCompare(b.branch)
: a.block.localeCompare(b.block)
: a.line - b.line,
);
for (const branch of branches) {
chunks.push(
`BRDA:${branch.line},${branch.block},${branch.branch},${branch.hits === null ? '-' : branch.hits}`,
);
}
chunks.push(`BRF:${branches.length}`);
chunks.push(`BRH:${branches.filter((branch) => (branch.hits ?? 0) > 0).length}`);
const lines = [...record.lines.entries()].sort((a, b) => a[0] - b[0]);
for (const [lineNumber, hits] of lines) {
chunks.push(`DA:${lineNumber},${hits}`);
}
chunks.push(`LF:${lines.length}`);
chunks.push(`LH:${lines.filter(([, hits]) => hits > 0).length}`);
chunks.push('end_of_record');
}
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
function runCoverageLane(): number {
const laneName = process.argv[2];
if (laneName === undefined) {
process.stderr.write('Missing coverage lane name\n');
return 1;
}
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true });
mkdirSync(shardRoot, { recursive: true });
const files = getLaneFiles(laneName);
const reports: string[] = [];
try {
for (const [index, file] of files.entries()) {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
const result = spawnSync(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
},
);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
return result.status ?? 1;
}
const lcovPath = join(shardDir, 'lcov.info');
if (!existsSync(lcovPath)) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
continue;
}
reports.push(readFileSync(lcovPath, 'utf8'));
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
return 0;
} finally {
rmSync(shardRoot, { recursive: true, force: true });
}
}
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
if (import.meta.main) {
process.exit(runCoverageLane());
}

View File

@@ -33,7 +33,7 @@ function runBash(args: string[]) {
}
function parseArtifactDir(stdout: string): string {
const match = stdout.match(/^artifact_dir=(.+)$/m);
const match = stdout.match(/^artifacts: (.+)$/m);
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
return match[1] ?? '';
}
@@ -42,10 +42,17 @@ function readSummaryJson(artifactDir: string) {
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
sessionId: string;
status: string;
selectedLanes: string[];
lanes: string[];
blockers?: string[];
artifactDir: string;
pathSelectionMode?: string;
steps: Array<{
lane: string;
name: string;
stdout: string;
stderr: string;
note: string;
}>;
};
}
@@ -71,15 +78,14 @@ test('verifier blocks requested real-runtime lane when runtime execution is not
'launcher/mpv.ts',
]);
assert.notEqual(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=blocked$/m);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
assert.deepEqual(summary.lanes, ['real-runtime']);
assert.ok(summary.sessionId.length > 0);
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
});
});
@@ -96,16 +102,81 @@ test('verifier fails closed for unknown lanes', () => {
'src/main.ts',
]);
assert.notEqual(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=failed$/m);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'failed');
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.lanes, ['not-a-lane']);
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
});
});
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'docs',
'--lane',
'not-a-lane',
'src/main.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
const unknownStep = summary.steps.find(
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
);
assert.ok(docsStep);
assert.ok(unknownStep);
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
});
});
test('verifier records the real-runtime lease blocker once', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const leaseDir = path.join(
repoRoot,
'.tmp',
'skill-verification',
'locks',
'exclusive-real-runtime',
);
fs.mkdirSync(leaseDir, { recursive: true });
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
try {
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--allow-real-runtime',
'--lane',
'real-runtime',
'launcher/mpv.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
} finally {
fs.rmSync(leaseDir, { recursive: true, force: true });
}
});
});
test('verifier allocates unique session ids and artifact roots by default', () => {
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
@@ -121,9 +192,9 @@ test('verifier allocates unique session ids and artifact roots by default', () =
const secondSummary = readSummaryJson(secondArtifactDir);
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
assert.equal(firstSummary.pathSelectionMode, 'explicit');
assert.equal(secondSummary.pathSelectionMode, 'explicit');
assert.notEqual(firstArtifactDir, secondArtifactDir);
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
} finally {
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
fs.rmSync(secondArtifactDir, { recursive: true, force: true });

View File

@@ -822,6 +822,92 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual toggle-off ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(
recorded ~= nil,
"plugin failed to load for repeated ready restore suppression scenario: " .. tostring(err)
)
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off should suppress repeated ready-time visible overlay restores for the same session"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"script-message toggle should issue explicit visible-overlay toggle command"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle") == 0,
"script-message toggle should not issue legacy generic toggle command"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",

View File

@@ -47,8 +47,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
const expectedSums = [appImagePath, wrapperPath, assetsPath].map(
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
);
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);

View File

@@ -1,4 +1,4 @@
import type { AnkiConnectConfig } from './types';
import type { AnkiConnectConfig } from './types/anki';
type NoteFieldValue = { value?: string } | string | null | undefined;
@@ -8,7 +8,9 @@ function normalizeFieldName(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function getConfiguredWordFieldName(config?: Pick<AnkiConnectConfig, 'fields'> | null): string {
export function getConfiguredWordFieldName(
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string {
return normalizeFieldName(config?.fields?.word) ?? 'Expression';
}

View File

@@ -21,15 +21,15 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { MediaGenerator } from './media-generator';
import path from 'path';
import {
AiConfig,
AnkiConnectConfig,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
MpvClient,
NotificationOptions,
NPlusOneMatchMode,
} from './types';
} from './types/anki';
import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime';
import { NPlusOneMatchMode } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import {
getConfiguredWordFieldCandidates,
@@ -212,10 +212,7 @@ export class AnkiIntegration {
try {
this.recordCardsMinedCallback(count, noteIds);
} catch (error) {
log.warn(
`recordCardsMined callback failed during ${source}:`,
(error as Error).message,
);
log.warn(`recordCardsMined callback failed during ${source}:`, (error as Error).message);
}
}

View File

@@ -1,4 +1,4 @@
import type { AiConfig } from '../types';
import type { AiConfig } from '../types/integrations';
import { requestAiChatCompletion } from '../ai/client';
const DEFAULT_AI_SYSTEM_PROMPT =

View File

@@ -4,10 +4,10 @@ import test from 'node:test';
import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync';
test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => {
assert.deepEqual(
extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'),
['word.mp3', 'alt.ogg'],
);
assert.deepEqual(extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), [
'word.mp3',
'alt.ogg',
]);
});
test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => {

View File

@@ -4,7 +4,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
type NoteInfoLike = {
noteId: number;
@@ -36,9 +36,7 @@ export function extractSoundFilenames(value: string): string[] {
}
function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'media'>): boolean {
return (
config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false
);
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
export async function probeAudioDurationSeconds(

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
test('CardCreationService counts locally created sentence cards', async () => {
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];

View File

@@ -3,10 +3,11 @@ import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AiConfig, AnkiConnectConfig } from '../types';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { MpvClient } from '../types';
import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source';

View File

@@ -179,7 +179,10 @@ function getDuplicateSourceCandidates(
const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) {
candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback });
candidates.push({
fieldName: configuredFieldNames[0] || 'Expression',
value: trimmedFallback,
});
}
}

View File

@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
FieldGroupingMergeCollaborator,
type FieldGroupingMergeNoteInfo,
} from './field-grouping-merge';
import type { AnkiConnectConfig } from '../types/anki';
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
return (
availableFieldNames.find(
(name) => name === preferredName || name.toLowerCase() === preferredName.toLowerCase(),
) ?? null
);
}
function createCollaborator(
options: {
config?: Partial<AnkiConnectConfig>;
currentSubtitleText?: string;
generatedMedia?: {
audioField?: string;
audioValue?: string;
imageField?: string;
imageValue?: string;
miscInfoValue?: string;
};
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
} = {},
) {
const warnings = options.warnings ?? [];
const config = {
fields: {
sentence: 'Sentence',
audio: 'ExpressionAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
...(options.config?.fields ?? {}),
},
...(options.config ?? {}),
} as AnkiConnectConfig;
return {
collaborator: new FieldGroupingMergeCollaborator({
getConfig: () => config,
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
}),
getCurrentSubtitleText: () => options.currentSubtitleText,
resolveFieldName,
resolveNoteFieldName: (noteInfo, preferredName) => {
if (!preferredName) return null;
return resolveFieldName(Object.keys(noteInfo.fields), preferredName);
},
extractFields: (fields) =>
Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key.toLowerCase(), value.value || '']),
),
processSentence: (mpvSentence) => `${mpvSentence}::processed`,
generateMediaForMerge: async () => options.generatedMedia ?? {},
warnFieldParseOnce: (fieldName, reason, detail) => {
warnings.push({ fieldName, reason, detail });
},
}),
warnings,
};
}
function makeNote(noteId: number, fields: Record<string, string>): FieldGroupingMergeNoteInfo {
return {
noteId,
fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, { value }])),
};
}
test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => {
const { collaborator } = createCollaborator({
config: {
fields: {
image: 'Illustration',
sentence: 'SentenceText',
audio: 'ExpressionAudio',
miscInfo: 'ExtraInfo',
},
},
});
assert.deepEqual(collaborator.getGroupableFieldNames(), [
'Sentence',
'SentenceAudio',
'Picture',
'Illustration',
'SentenceText',
'ExtraInfo',
'SentenceFurigana',
]);
});
test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => {
const { collaborator } = createCollaborator({
config: {
fields: {
audio: 'CustomAudio',
},
},
});
const merged = await collaborator.computeFieldGroupingMergedFields(
1,
2,
makeNote(1, {
SentenceAudio: '[sound:keep.mp3]',
CustomAudio: '[sound:stale.mp3]',
}),
makeNote(2, {
SentenceAudio: '[sound:new.mp3]',
}),
false,
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
);
assert.equal(merged.CustomAudio, merged.SentenceAudio);
});
test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => {
const { collaborator, warnings } = createCollaborator({
currentSubtitleText: 'subtitle line',
});
const merged = await collaborator.computeFieldGroupingMergedFields(
3,
4,
makeNote(3, {
Sentence: '<span data-group-id="abc">keep sentence</span>',
SentenceAudio: '',
}),
makeNote(4, {
Sentence: 'source sentence',
SentenceAudio: '[sound:source.mp3]',
}),
false,
);
assert.equal(
merged.Sentence,
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>',
);
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
assert.equal(warnings.length, 4);
assert.deepEqual(
warnings.map((entry) => entry.reason),
['invalid-group-id', 'no-usable-span-entries', 'invalid-group-id', 'no-usable-span-entries'],
);
});
test('computeFieldGroupingMergedFields uses generated media only when includeGeneratedMedia is true', async () => {
const generatedMedia = {
audioField: 'SentenceAudio',
audioValue: '[sound:generated.mp3]',
imageField: 'Picture',
imageValue: '<img src="generated.png">',
miscInfoValue: 'generated misc',
};
const { collaborator: withoutGenerated } = createCollaborator({ generatedMedia });
const { collaborator: withGenerated } = createCollaborator({ generatedMedia });
const keep = makeNote(10, {
SentenceAudio: '',
Picture: '',
MiscInfo: '',
});
const source = makeNote(11, {
SentenceAudio: '',
Picture: '',
MiscInfo: '',
});
const without = await withoutGenerated.computeFieldGroupingMergedFields(
10,
11,
keep,
source,
false,
);
const withMedia = await withGenerated.computeFieldGroupingMergedFields(
10,
11,
keep,
source,
true,
);
assert.deepEqual(without, {});
assert.equal(withMedia.SentenceAudio, '<span data-group-id="11">[sound:generated.mp3]</span>');
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});

View File

@@ -1,4 +1,4 @@
import { AnkiConnectConfig } from '../types';
import { AnkiConnectConfig } from '../types/anki';
import { getConfiguredWordFieldName } from '../anki-field-config';
interface FieldGroupingMergeMedia {

Some files were not shown because too many files have changed in this diff Show More