From 37e849b9110a9fcd34e9c02fe2ba226b4dcb7330 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 8 Oct 2026 21:56:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(skills):=20git=20status=20=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=82=81=E3=81=AA=E3=81=8B=E3=81=A3=E3=81=9F=E3=81=A8?= =?UTF-8?q?=E3=81=8D=20skills=20=E3=81=AE=E8=A8=88=E7=94=BB=E3=81=A8=20CLI?= =?UTF-8?q?=20=E3=81=8C=E3=80=8C=E4=B8=8D=E6=98=8E=E3=80=8D=E3=81=A8?= =?UTF-8?q?=E5=87=BA=E3=81=99=20(#3445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3435 で skills の写し(readSkillGitTargetState)は読めなかった git status を dirty 側に倒しただけで、計画・画面・CLI が「未コミットの変更あり」と出していた。 表し方の根拠(#3435 の isDirty / statusUnknown に合わせる): - SkillGitTargetState.dirty は boolean のまま(不明でも true = 安全側)。 dirtyUnknown?: boolean を足し、読めなかったときだけ付ける(成功時はキー無し)。 - 計画の target DTO も workingTreeDirty: boolean は据え置き、 workingTreeUnknown?: true を不明のときだけ足す。三値化しないので既存の読み手 (SkillTargetSelector、CLI、e2e fixture、integration テスト)は壊れない。 - 警告は不明のとき WORKING_TREE_DIRTY の代わりに新設の SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN を出す(警告であることは保つ。clean にはしない)。install と update は workingTreeWarning() を共有する。 - 表示: 画面は target.workingTreeStatusUnknown / plan.warning.workingTreeStatusUnknown を ja / en にそろえた。CLI は ' [working tree status unknown]'(dirty / clean の 出力はバイト不変)。 対になる場所の洗い出し(grep workingTreeDirty|WORKING_TREE_DIRTY src tests docs): - 直した: preview-diff.ts(状態・警告)、install-plan / update-plan / uninstall-plan (DTO)、cli/types/api-responses.ts の 3 つの写し、cli/commands/skill-format.ts の 3 箇所、SkillPlanPreview.tsx の 2 箇所、skill-vocabulary.ts の警告ラベル、 locales/{ja,en}/skills.json。 - 直さない: SkillTargetSelector.tsx は #3435 で既に statusUnknown を null(未確認) として扱っている。tests/e2e/fixtures/skills-helpers.ts と tests/integration/skills-mvp-security-regression.test.ts は任意フィールドの追加で 影響なし(integration は本当に dirty な場合で WORKING_TREE_DIRTY のまま)。 docs には workingTreeDirty の記述無し。CLI の formatter は英語のみで ja 文言の 置き場所は無い(従来の ' [working tree dirty]' も英語のみ)。 - 古い動きを固定したテスト: git-status-unknown-3435 の「failed status を dirty と 扱う」は dirty=true を保つので変えずに通る。 テスト: git-status-unknown-3435 に dirtyUnknown の describe、uninstall-plan に DTO の describe、working-tree-unknown-3445(警告と CLI 表示)、 skill-plan-working-tree-unknown-3445(画面)を追加。 Co-Authored-By: Claude Opus 5.5 --- changelog.d/3445.md | 2 + locales/en/skills.json | 2 + locales/ja/skills.json | 2 + src/cli/commands/skill-format.ts | 18 ++++- src/cli/types/api-responses.ts | 6 ++ src/components/skills/SkillPlanPreview.tsx | 12 +++- src/components/skills/skill-vocabulary.ts | 1 + src/lib/skills/install-plan.ts | 6 ++ src/lib/skills/preview-diff.ts | 25 ++++++- src/lib/skills/uninstall-plan.ts | 6 ++ src/lib/skills/update-plan.ts | 10 ++- ...ll-plan-working-tree-unknown-3445.test.tsx | 71 +++++++++++++++++++ .../lib/git/git-status-unknown-3435.test.ts | 25 +++++++ tests/unit/lib/skills/uninstall-plan.test.ts | 23 ++++++ .../skills/working-tree-unknown-3445.test.ts | 36 ++++++++++ 15 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 changelog.d/3445.md create mode 100644 tests/unit/components/skills/skill-plan-working-tree-unknown-3445.test.tsx create mode 100644 tests/unit/lib/skills/working-tree-unknown-3445.test.ts diff --git a/changelog.d/3445.md b/changelog.d/3445.md new file mode 100644 index 000000000..be9cd0d26 --- /dev/null +++ b/changelog.d/3445.md @@ -0,0 +1,2 @@ + +- **fix(skills): git status を読めなかったとき skills の計画と CLI が「変更あり」ではなく「不明」と出す** (#3445): install / update / uninstall の計画に `workingTreeUnknown`(読めたときはキー無し)と警告 `SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN` を足し、画面(ja / en)と CLI(`[working tree status unknown]`)で未コミットの変更ありと区別する。clean とは表示しない diff --git a/locales/en/skills.json b/locales/en/skills.json index 07037b2e7..68bb96405 100644 --- a/locales/en/skills.json +++ b/locales/en/skills.json @@ -165,6 +165,7 @@ "workingTree": "Working tree", "workingTreeClean": "No uncommitted changes", "workingTreeDirty": "Has uncommitted changes", + "workingTreeStatusUnknown": "Could not check for uncommitted changes", "workingTreeUnknown": "Not inspected", "sessionRunning": "A session is currently running in this worktree.", "branchFreshnessNotice": "The branch shown here comes from the last worktree scan. CommandMate resolves the live branch and commit when it builds the install plan, and reports any difference there." @@ -215,6 +216,7 @@ "unbornHead": "This worktree has no commit yet.", "headUnresolved": "The current commit could not be resolved.", "workingTreeDirty": "This worktree has uncommitted changes, so the install would mix with work in progress.", + "workingTreeStatusUnknown": "git status could not be read, so this worktree may have uncommitted changes that the install would mix with.", "pathGitIgnored": "Some destination paths are ignored by git, so the installed files would not show up in git status.", "diffTruncated": "Some diffs were too large to show in full.", "binaryContent": "Some files are binary, so no diff can be shown for them.", diff --git a/locales/ja/skills.json b/locales/ja/skills.json index 8df6ae46b..8e581ff7f 100644 --- a/locales/ja/skills.json +++ b/locales/ja/skills.json @@ -165,6 +165,7 @@ "workingTree": "作業ツリー", "workingTreeClean": "未コミットの変更なし", "workingTreeDirty": "未コミットの変更あり", + "workingTreeStatusUnknown": "未コミットの変更の有無を確認できませんでした", "workingTreeUnknown": "未確認", "sessionRunning": "このworktreeではセッションが稼働中です。", "branchFreshnessNotice": "ここに表示しているブランチは直近のworktree同期時点の値です。CommandMateはinstall plan生成時に現在のブランチとコミットを解決し、差異があればそこで報告します。" @@ -215,6 +216,7 @@ "unbornHead": "このworktreeにはまだコミットがありません。", "headUnresolved": "現在のコミットを解決できませんでした。", "workingTreeDirty": "このworktreeには未コミットの変更があるため、導入内容が作業中の変更と混ざります。", + "workingTreeStatusUnknown": "git status を読めなかったため、このworktreeに未コミットの変更があるか分かりません。変更がある場合、導入内容が作業中の変更と混ざります。", "pathGitIgnored": "一部の書き込み先はgitのignore対象です。導入したファイルはgit statusに現れません。", "diffTruncated": "一部の差分は大きすぎるため省略されています。", "binaryContent": "一部のファイルはバイナリのため差分を表示できません。", diff --git a/src/cli/commands/skill-format.ts b/src/cli/commands/skill-format.ts index 1c25d4ea0..ae27effae 100644 --- a/src/cli/commands/skill-format.ts +++ b/src/cli/commands/skill-format.ts @@ -113,6 +113,18 @@ export function formatInstallRoots(roots: readonly string[] | undefined, primary return roots && roots.length > 1 ? roots.join(', ') : primary; } +/** + * The working-tree mark after the branch (Issue #3445). An unread `git status` + * is flagged as unknown rather than dirty, and never as clean. + */ +export function formatWorkingTreeMark(target: { + workingTreeDirty: boolean; + workingTreeUnknown?: boolean; +}): string { + if (target.workingTreeUnknown) return ' [working tree status unknown]'; + return target.workingTreeDirty ? ' [working tree dirty]' : ''; +} + /** The install preview: what lands, where, at what risk, and what stands in the way. */ export function formatInstallPlan(plan: SkillInstallPlan): string { const { skill, target, stats } = plan; @@ -121,7 +133,7 @@ export function formatInstallPlan(plan: SkillInstallPlan): string { skill.summary, '', `Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`, - `Branch: ${target.branch ?? `(${target.headState})`}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`, + `Branch: ${target.branch ?? `(${target.headState})`}${formatWorkingTreeMark(target)}`, `Install root: ${formatInstallRoots(target.installRoots, target.installRoot)}`, `Risk: ${skill.effectiveRisk} — ${skill.riskRationale}`, `Compatibility: ${skill.compatibility.commandmate.status} — ${skill.compatibility.commandmate.message}`, @@ -173,7 +185,7 @@ export function formatUpdatePlan(plan: SkillUpdatePlan): string { `Update plan: ${skill.name} (${skill.id}) ${update.fromVersion} -> ${update.toVersion}`, '', `Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`, - `Branch: ${target.branch ?? `(${target.headState})`}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`, + `Branch: ${target.branch ?? `(${target.headState})`}${formatWorkingTreeMark(target)}`, `Install root: ${formatInstallRoots(target.installRoots, target.installRoot)}`, `Risk: ${securityDiff.risk.from.effective} -> ${securityDiff.risk.to.effective}${securityDiff.risk.increased ? ' [RISK INCREASE]' : ''}`, `Compatibility: ${skill.compatibility.commandmate.status} — ${skill.compatibility.commandmate.message}`, @@ -248,7 +260,7 @@ export function formatUninstallPlan(plan: SkillUninstallPlan): string { `Uninstall plan: ${skill.id} ${skill.version}`, '', `Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`, - `Branch: ${target.branch ?? '(detached)'}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`, + `Branch: ${target.branch ?? '(detached)'}${formatWorkingTreeMark(target)}`, `Install root: ${target.installRoot}`, `Risk: ${skill.effectiveRisk}`, `Files: ${stats.removable} removable, ${stats.modified} locally modified, ${stats.missing} missing, ${stats.unknown} unmanaged, ${stats.irregular} irregular`, diff --git a/src/cli/types/api-responses.ts b/src/cli/types/api-responses.ts index 418a5a399..249065f8d 100644 --- a/src/cli/types/api-responses.ts +++ b/src/cli/types/api-responses.ts @@ -1543,6 +1543,8 @@ export interface SkillInstallPlan { branch: string | null; headState: string; workingTreeDirty: boolean; + /** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */ + workingTreeUnknown?: boolean; /** Repository-relative; the server never serves a machine-absolute path. */ installRoot: string; /** Every root the package is placed into, primary first (#1460). */ @@ -1653,6 +1655,8 @@ export interface SkillUninstallPlan { repositoryName: string; branch: string | null; workingTreeDirty: boolean; + /** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */ + workingTreeUnknown?: boolean; installRoot: string; }; skill: { id: string; version: string; effectiveRisk: string }; @@ -1704,6 +1708,8 @@ export interface SkillUpdatePlan { branch: string | null; headState: string; workingTreeDirty: boolean; + /** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */ + workingTreeUnknown?: boolean; /** Repository-relative; the server never serves a machine-absolute path. */ installRoot: string; /** Every recorded root the update rewrites, primary first (#1460). */ diff --git a/src/components/skills/SkillPlanPreview.tsx b/src/components/skills/SkillPlanPreview.tsx index 3ecb699f1..634f792e0 100644 --- a/src/components/skills/SkillPlanPreview.tsx +++ b/src/components/skills/SkillPlanPreview.tsx @@ -139,7 +139,11 @@ export function SkillInstallPlanPreview({ plan }: SkillInstallPlanPreviewProps) {t(HEAD_STATE_LABEL_KEY[target.headState] ?? 'plan.headState.unknown')} - {target.workingTreeDirty ? t('target.workingTreeDirty') : t('target.workingTreeClean')} + {target.workingTreeUnknown + ? t('target.workingTreeStatusUnknown') + : target.workingTreeDirty + ? t('target.workingTreeDirty') + : t('target.workingTreeClean')} @@ -367,7 +371,11 @@ export function SkillUninstallPlanPreview({ plan }: SkillUninstallPlanPreviewPro {target.installRoot} - {target.workingTreeDirty ? t('target.workingTreeDirty') : t('target.workingTreeClean')} + {target.workingTreeUnknown + ? t('target.workingTreeStatusUnknown') + : target.workingTreeDirty + ? t('target.workingTreeDirty') + : t('target.workingTreeClean')} diff --git a/src/components/skills/skill-vocabulary.ts b/src/components/skills/skill-vocabulary.ts index b811422b0..9c1c3e92c 100644 --- a/src/components/skills/skill-vocabulary.ts +++ b/src/components/skills/skill-vocabulary.ts @@ -178,6 +178,7 @@ export const PREVIEW_WARNING_LABEL_KEY: Record = { SKILL_PREVIEW_UNBORN_HEAD: 'plan.warning.unbornHead', SKILL_PREVIEW_HEAD_UNRESOLVED: 'plan.warning.headUnresolved', SKILL_PREVIEW_WORKING_TREE_DIRTY: 'plan.warning.workingTreeDirty', + SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN: 'plan.warning.workingTreeStatusUnknown', SKILL_PREVIEW_PATH_GIT_IGNORED: 'plan.warning.pathGitIgnored', SKILL_PREVIEW_DIFF_TRUNCATED: 'plan.warning.diffTruncated', SKILL_PREVIEW_BINARY_CONTENT: 'plan.warning.binaryContent', diff --git a/src/lib/skills/install-plan.ts b/src/lib/skills/install-plan.ts index 90e6db716..5e1632c9b 100644 --- a/src/lib/skills/install-plan.ts +++ b/src/lib/skills/install-plan.ts @@ -303,6 +303,11 @@ export interface SkillPlanTargetDto { headState: SkillGitTargetState['headState']; headCommit: string | null; workingTreeDirty: boolean; + /** + * `git status` could not be read (Issue #3445). `workingTreeDirty` is then + * true as the fail-safe guess; present only when true. + */ + workingTreeUnknown?: boolean; /** Repository-relative primary install root (`.agents/skills/`). */ installRoot: string; /** Every repository-relative root the package will be placed into, primary first (#1460). */ @@ -791,6 +796,7 @@ export async function createSkillInstallPlan( headState: git.headState, headCommit: git.headCommit, workingTreeDirty: git.dirty, + ...(git.dirtyUnknown ? { workingTreeUnknown: true } : {}), installRoot: primary.preview.installRoot, installRoots, currentTreeHash: primary.preview.currentTreeHash, diff --git a/src/lib/skills/preview-diff.ts b/src/lib/skills/preview-diff.ts index e408b6644..ee0db52f6 100644 --- a/src/lib/skills/preview-diff.ts +++ b/src/lib/skills/preview-diff.ts @@ -101,6 +101,8 @@ export const SkillPreviewWarning = { HEAD_UNRESOLVED: 'SKILL_PREVIEW_HEAD_UNRESOLVED', /** The worktree has uncommitted changes. */ WORKING_TREE_DIRTY: 'SKILL_PREVIEW_WORKING_TREE_DIRTY', + /** `git status` could not be read, so uncommitted changes may exist (Issue #3445). */ + WORKING_TREE_STATUS_UNKNOWN: 'SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN', /** At least one destination path is ignored or excluded by git. */ PATH_GIT_IGNORED: 'SKILL_PREVIEW_PATH_GIT_IGNORED', /** At least one diff body was cut short. */ @@ -175,6 +177,11 @@ export interface SkillGitTargetState { headCommit: string | null; /** The worktree has uncommitted changes (also true when `git status` could not be read). */ dirty: boolean; + /** + * `git status` could not be read, so `dirty` is the fail-safe guess rather + * than a fact (Issue #3445). Present only when true, like `GitStatus.statusUnknown`. + */ + dirtyUnknown?: boolean; } /** A file the package would write, as the diff builder needs it. */ @@ -488,6 +495,9 @@ export async function readSkillGitTargetState( // read as clean. This flag only drives the advisory WORKING_TREE_DIRTY // warning and the target display, so the fail-safe side is "dirty". dirty: status === null || status.length > 0, + // Issue #3445: say the guess is a guess, so the display can tell "has + // changes" from "could not check". Absent on success (shape unchanged). + ...(status === null ? { dirtyUnknown: true } : {}), }; } @@ -812,6 +822,18 @@ export function buildSkillPreviewDiff(input: BuildPreviewInput): SkillPreviewDif }; } +/** + * The working-tree caveat for a target (Issue #3445): an unread status is still + * a caveat (fail-safe), but worded as "could not check" rather than "dirty". + */ +export function workingTreeWarning( + git: Pick +): SkillPreviewWarningCode | null { + if (git.dirtyUnknown) return SkillPreviewWarning.WORKING_TREE_STATUS_UNKNOWN; + if (git.dirty) return SkillPreviewWarning.WORKING_TREE_DIRTY; + return null; +} + function collectWarnings( input: BuildPreviewInput, entries: readonly SkillDiffEntry[], @@ -822,7 +844,8 @@ function collectWarnings( if (input.git.headState === 'detached') warnings.push(SkillPreviewWarning.DETACHED_HEAD); if (input.git.headState === 'unborn') warnings.push(SkillPreviewWarning.UNBORN_HEAD); if (input.git.headState === 'unknown') warnings.push(SkillPreviewWarning.HEAD_UNRESOLVED); - if (input.git.dirty) warnings.push(SkillPreviewWarning.WORKING_TREE_DIRTY); + const dirtyWarning = workingTreeWarning(input.git); + if (dirtyWarning) warnings.push(dirtyWarning); if (entries.some((entry) => entry.gitIgnored)) warnings.push(SkillPreviewWarning.PATH_GIT_IGNORED); if (stats.truncatedFiles > 0) warnings.push(SkillPreviewWarning.DIFF_TRUNCATED); if (stats.binaryFiles > 0) warnings.push(SkillPreviewWarning.BINARY_CONTENT); diff --git a/src/lib/skills/uninstall-plan.ts b/src/lib/skills/uninstall-plan.ts index cbc8ebade..4d06f6258 100644 --- a/src/lib/skills/uninstall-plan.ts +++ b/src/lib/skills/uninstall-plan.ts @@ -363,6 +363,11 @@ export interface SkillUninstallTargetDto { headState: SkillGitTargetState['headState']; headCommit: string | null; workingTreeDirty: boolean; + /** + * `git status` could not be read (Issue #3445). `workingTreeDirty` is then + * true as the fail-safe guess; present only when true. + */ + workingTreeUnknown?: boolean; installRoot: string; currentTreeHash: string; } @@ -674,6 +679,7 @@ export function createSkillUninstallPlan( headState: input.git.headState, headCommit: input.git.headCommit, workingTreeDirty: input.git.dirty, + ...(input.git.dirtyUnknown ? { workingTreeUnknown: true } : {}), installRoot: assessment.installRoot, currentTreeHash: assessment.currentTreeHash, }, diff --git a/src/lib/skills/update-plan.ts b/src/lib/skills/update-plan.ts index 73ca46884..c36783534 100644 --- a/src/lib/skills/update-plan.ts +++ b/src/lib/skills/update-plan.ts @@ -48,6 +48,7 @@ import { import { SKILL_DIFF_MAX_TOTAL_BYTES, SkillPreviewWarning, + workingTreeWarning, buildUnifiedDiff, computeSkillTreeHash, isBinaryContent, @@ -260,6 +261,11 @@ export interface SkillUpdateTargetDto { headState: SkillGitTargetState['headState']; headCommit: string | null; workingTreeDirty: boolean; + /** + * `git status` could not be read (Issue #3445). `workingTreeDirty` is then + * true as the fail-safe guess; present only when true. + */ + workingTreeUnknown?: boolean; /** Primary install root; the binding's tree hashes are anchored here. */ installRoot: string; /** Every recorded root, primary first. The receipt's set is authoritative (#1460). */ @@ -1042,7 +1048,8 @@ export function createSkillUpdatePlan(input: CreateSkillUpdatePlanInput): SkillU if (input.git.headState === 'detached') warnings.push(SkillPreviewWarning.DETACHED_HEAD); if (input.git.headState === 'unborn') warnings.push(SkillPreviewWarning.UNBORN_HEAD); if (input.git.headState === 'unknown') warnings.push(SkillPreviewWarning.HEAD_UNRESOLVED); - if (input.git.dirty) warnings.push(SkillPreviewWarning.WORKING_TREE_DIRTY); + const dirtyWarning = workingTreeWarning(input.git); + if (dirtyWarning) warnings.push(dirtyWarning); if (anyBinary) warnings.push(SkillPreviewWarning.BINARY_CONTENT); if (anyTruncated) warnings.push(SkillPreviewWarning.DIFF_TRUNCATED); if (roots.some((root) => root.existing.truncated)) { @@ -1102,6 +1109,7 @@ export function createSkillUpdatePlan(input: CreateSkillUpdatePlanInput): SkillU headState: input.git.headState, headCommit: input.git.headCommit, workingTreeDirty: input.git.dirty, + ...(input.git.dirtyUnknown ? { workingTreeUnknown: true } : {}), installRoot: primary.rel, installRoots: roots.map((root) => root.rel), roots: roots.map((root) => ({ diff --git a/tests/unit/components/skills/skill-plan-working-tree-unknown-3445.test.tsx b/tests/unit/components/skills/skill-plan-working-tree-unknown-3445.test.tsx new file mode 100644 index 000000000..8bbf36d06 --- /dev/null +++ b/tests/unit/components/skills/skill-plan-working-tree-unknown-3445.test.tsx @@ -0,0 +1,71 @@ +/** + * Issue #3445: the plan previews tell "has uncommitted changes" apart from + * "could not check" (git status unread), and never show the latter as clean. + * + * @vitest-environment jsdom + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: + (namespace?: string) => + (key: string, params?: Record) => { + const full = namespace ? `${namespace}.${key}` : key; + if (!params) return full; + const rendered = Object.entries(params) + .map(([name, value]) => `${name}=${value}`) + .join(','); + return `${full}(${rendered})`; + }, + useLocale: () => 'en', +})); + +import { + SkillInstallPlanPreview, + SkillUninstallPlanPreview, +} from '@/components/skills/SkillPlanPreview'; +import { makeInstallPlan, makeUninstallPlan } from './fixtures'; + +describe('SkillPlanPreview working tree (Issue #3445)', () => { + it('install: shows "status unknown" and its caveat, not dirty or clean', () => { + const base = makeInstallPlan(); + render( + + ); + + expect(screen.getByText('skills.target.workingTreeStatusUnknown')).toBeInTheDocument(); + expect(screen.getByText('skills.plan.warning.workingTreeStatusUnknown')).toBeInTheDocument(); + expect(screen.queryByText('skills.target.workingTreeDirty')).toBeNull(); + expect(screen.queryByText('skills.target.workingTreeClean')).toBeNull(); + }); + + it('install: dirty still reads as dirty', () => { + const base = makeInstallPlan(); + render( + + ); + + expect(screen.getByText('skills.target.workingTreeDirty')).toBeInTheDocument(); + }); + + it('uninstall: shows "status unknown"', () => { + const base = makeUninstallPlan(); + render( + + ); + + expect(screen.getByText('skills.target.workingTreeStatusUnknown')).toBeInTheDocument(); + expect(screen.queryByText('skills.target.workingTreeDirty')).toBeNull(); + }); +}); diff --git a/tests/unit/lib/git/git-status-unknown-3435.test.ts b/tests/unit/lib/git/git-status-unknown-3435.test.ts index 2a5ab0c22..dcceed159 100644 --- a/tests/unit/lib/git/git-status-unknown-3435.test.ts +++ b/tests/unit/lib/git/git-status-unknown-3435.test.ts @@ -117,3 +117,28 @@ describe('readSkillGitTargetState when status cannot be read (Issue #3435)', () expect((await readSkillGitTargetState('/repo')).dirty).toBe(true); }); }); + +describe('readSkillGitTargetState marks an unread status as unknown (Issue #3445)', () => { + const sha = 'a'.repeat(40); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets dirtyUnknown alongside the fail-safe dirty', async () => { + stubGit(timeoutError(), { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + + const state = await readSkillGitTargetState('/repo'); + + expect(state.dirty).toBe(true); + expect(state.dirtyUnknown).toBe(true); + }); + + it('leaves the key out on success (clean or dirty)', async () => { + stubGit({ stdout: '' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + expect('dirtyUnknown' in (await readSkillGitTargetState('/repo'))).toBe(false); + + stubGit({ stdout: ' M a.ts\n' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + expect('dirtyUnknown' in (await readSkillGitTargetState('/repo'))).toBe(false); + }); +}); diff --git a/tests/unit/lib/skills/uninstall-plan.test.ts b/tests/unit/lib/skills/uninstall-plan.test.ts index bcada146e..2031ade00 100644 --- a/tests/unit/lib/skills/uninstall-plan.test.ts +++ b/tests/unit/lib/skills/uninstall-plan.test.ts @@ -481,3 +481,26 @@ describe('consumeSkillUninstallPlan — the token contract', () => { ).toThrowError(/SKILL_PLAN_NOT_INSTALLABLE/); }); }); + +describe('createSkillUninstallPlan — unread git status (Issue #3445)', () => { + it('reports workingTreeUnknown and keeps the fail-safe dirty flag', () => { + install(); + + const result = createSkillUninstallPlan({ + actor: ACTOR, + worktree: { id: WORKTREE_ID, name: 'demo-worktree', path: worktree, repositoryName: 'CommandMate' }, + skillId: SKILL_ID, + installRootAbs: installRoot(), + git: { ...GIT, dirty: true, dirtyUnknown: true }, + }); + + expect(result.dto.target.workingTreeDirty).toBe(true); + expect(result.dto.target.workingTreeUnknown).toBe(true); + }); + + it('leaves workingTreeUnknown out when status was read', () => { + install(); + + expect('workingTreeUnknown' in plan().dto.target).toBe(false); + }); +}); diff --git a/tests/unit/lib/skills/working-tree-unknown-3445.test.ts b/tests/unit/lib/skills/working-tree-unknown-3445.test.ts new file mode 100644 index 000000000..573a40b07 --- /dev/null +++ b/tests/unit/lib/skills/working-tree-unknown-3445.test.ts @@ -0,0 +1,36 @@ +/** + * Issue #3445: when `git status` could not be read, the skills plans and the + * CLI say "unknown" rather than "dirty" — and still never "clean". + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; +import { SkillPreviewWarning, workingTreeWarning } from '@/lib/skills/preview-diff'; +import { formatWorkingTreeMark } from '@/cli/commands/skill-format'; + +describe('workingTreeWarning', () => { + it('warns "status unknown" (not dirty) when status could not be read', () => { + expect(workingTreeWarning({ dirty: true, dirtyUnknown: true })).toBe( + SkillPreviewWarning.WORKING_TREE_STATUS_UNKNOWN + ); + }); + + it('keeps dirty and clean as before', () => { + expect(workingTreeWarning({ dirty: true })).toBe(SkillPreviewWarning.WORKING_TREE_DIRTY); + expect(workingTreeWarning({ dirty: false })).toBeNull(); + }); +}); + +describe('formatWorkingTreeMark (CLI)', () => { + it('marks an unread status as unknown, never clean', () => { + expect(formatWorkingTreeMark({ workingTreeDirty: true, workingTreeUnknown: true })).toBe( + ' [working tree status unknown]' + ); + }); + + it('keeps dirty and clean output byte-identical', () => { + expect(formatWorkingTreeMark({ workingTreeDirty: true })).toBe(' [working tree dirty]'); + expect(formatWorkingTreeMark({ workingTreeDirty: false })).toBe(''); + }); +}); From 0bdd076f4517a931272bc3c784f1593612a91790 Mon Sep 17 00:00:00 2001 From: kewton Date: Fri, 9 Oct 2026 00:06:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(module-reference):=20Issue=20#3445=20?= =?UTF-8?q?=E3=81=AE=E6=B3=A8=E8=A8=98=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/module-reference.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/module-reference.md b/docs/module-reference.md index 4b079a410..33ff045c7 100644 --- a/docs/module-reference.md +++ b/docs/module-reference.md @@ -898,7 +898,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/config/composer-layout.ts` | composer のレイアウトしきい値(Issue #2598)。`COMPOSER_HINTS_MIN_CONTAINER_PX`(420): PC メタ行のキー操作ヒントを出す最小の composer 幅(`MessageInput` の `@min-[420px]:flex` と同値のリテラル必須)。Auto-Yes(有効時のカウントダウン込み)とヒントの実測幅の表を docblock に持つ | | `src/config/session-starting-config.ts` | 起動中表示の上限(Issue #3179)。`getSessionStartingMaxMs(tool)`=起動待ち(claude 60 s、他 30 s)+5 s、`SESSION_STARTING_PROMPT_GRACE_MS`(5 s)、`SESSION_STARTING_ELAPSED_THRESHOLD_MS`(5 s) Issue #3194: `getSessionStartingMaxMsAcrossTools()`(全ツールの `getSessionStartingMaxMs` の最大。claude 65 s)。 | | `src/app/api/skills/route.ts` / `[id]/route.ts` | read-only Catalog API。UI と CLI が同じ結果を参照する(#1231) | -| `src/components/skills/` | Catalog 一覧・詳細・badge・freshness banner・表示語彙。changelog は `stripRemoteMedia()` 適用(#1232) | +| `src/components/skills/` | Catalog 一覧・詳細・badge・freshness banner・表示語彙。changelog は `stripRemoteMedia()` 適用(#1232) **Issue #3445**: `SkillPlanPreview` の作業ツリー欄は `workingTreeUnknown` で `target.workingTreeStatusUnknown` を出す。`PREVIEW_WARNING_LABEL_KEY` に `plan.warning.workingTreeStatusUnknown`。 | | `src/lib/skills/operation-lock.ts` | owner nonce / lease / process generation つき cross-process lock。lease 失効後も owner 生存中は reclaim しない(same-process 含む。#1427 で same-pid ショートカット除去)。hash separator は `\x00` エスケープ(#1432)(#1234、server-only) | | `src/lib/skills/operation-journal.ts` | typed state transition と idempotency key binding。terminal entry は retention(7日)で自動 prune。`beginSkillOperation` の `isReplayable` が false の entry は supersede(PREPARING は対象外)(#1234 / #1428 / #1552、server-only) | | `src/lib/skills/operation-replay.ts` | replay 前提条件。commit 済み entry の主張を primary root の receipt 実在/digest で照合し、uninstall 後の再 install(と再 uninstall)を replay で握り潰さない(#1552、server-only) | @@ -906,15 +906,15 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/skills/operation-audit.ts` | append-only `skill_operations` への記録と読み取り。`querySkillOperationAudit` は worktree 省略で横断、operation/result/期間で絞り込み、`(recordedAt,id)` 複合 cursor で改ページ(同一 ms の tie でも重複・欠落なし)(#1234 / #1248、server-only) | | `src/lib/skills/operation-reconciler.ts` | on-demand reconciliation ロジックと orphan cleanup。起動時実行は `startup-reconcile` 経由(#1234 / #1428、server-only) | | `src/lib/db/migrations/v44-skill-operations.ts` | `skill_operations` テーブル。trigger で追記専用を DB 側から強制(#1234) | -| `src/lib/skills/install-plan.ts` | 決定的 receipt bytes 確定、plan token の server-side state(TTL/1回性/LRU)、drift 検出(#1233、server-only) | -| `src/lib/skills/preview-diff.ts` | install root 走査・live branch/HEAD 解決・git ignore 判定・virtual unified diff・tree hash(#1233、server-only) **Issue #3435**: `readSkillGitTargetState` は status 失敗を `dirty: true`(警告側)に倒す。 | +| `src/lib/skills/install-plan.ts` | 決定的 receipt bytes 確定、plan token の server-side state(TTL/1回性/LRU)、drift 検出(#1233、server-only) **Issue #3445**: target DTO に `workingTreeUnknown?: true`(不明のときだけ。`workingTreeDirty` は boolean のまま true)。 | +| `src/lib/skills/preview-diff.ts` | install root 走査・live branch/HEAD 解決・git ignore 判定・virtual unified diff・tree hash(#1233、server-only) **Issue #3435**: `readSkillGitTargetState` は status 失敗を `dirty: true`(警告側)に倒す。 **Issue #3445**: `SkillGitTargetState.dirtyUnknown?: boolean`(git status を読めなかったときだけ true、`dirty` は安全側の true のまま)。警告 `SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN` を追加し、`workingTreeWarning(git)` が不明→STATUS_UNKNOWN / dirty→WORKING_TREE_DIRTY / clean→null を返す(install・update で共有)。 | | `src/lib/skills/install-apply.ts` | `O_EXCL\|O_NOFOLLOW` write・same-filesystem staging・destination 不存在の自前証明・atomic rename(#1235、server-only) | | `src/lib/skills/installed-state.ts` | `skill_installations` の idempotent upsert と `listSkillInstallations`(worktree 単位の読み取り)。読み取りは #1440 の GET route から配線済み(#1235 / #1440) | | `src/lib/skills/status-scanner.ts` | 全登録 worktree 横断の適用状態走査。`installed`/`modified`/`missing`/`unmanaged`/`update_available` を算出。**走査 root は receipt の `install_roots` を正とし全 root を見る**(片側 root だけでは secondary の drift を見逃す)。走査は同期 I/O のため worker pool ではなく上限+worktree 毎の event loop yield+TTL cache で保護し、打ち切りは `truncated` で報告(#1248、server-only) | | `src/lib/skills/reindex.ts` | receipt から `skill_installations` を再構築。DB 削除後も復旧でき、復元 root 集合は receipt の `install_roots` と一致する。receipt 不在/破損/他 Skill 宛は index せず skip 理由付きで報告。payload と append-only log には一切書かない。入口は2つ: `reindexSkillInstallations(db, {now?})` は全登録 worktree の明示的一括修復(全行を receipt から書き直し・prune あり)、`restoreSkillInstallationIndex(db, {id,path}, {now?})` は worktree 単位の read-through 用で**欠落行の復元+receipt と食い違う行の収束・prune はしない**。収束判定は receipt bytes の sha256 と行の `receipt_sha256` の比較で、一致する行は parse も DB 書き込みもしない(#1753 のコスト設計。#1709 は receipt を読まず「行があれば正しい」と仮定していたため version drift を永久に検知できなかった)。戻り値の `converged>0` は API を経由しない disk 書き換えの痕跡(#1248 / #1709 / #1753、server-only) | -| `src/lib/skills/uninstall-plan.ts` | receipt 照合による remove/modified/missing/unknown/irregular 分類。drift 観測に receipt digest を含む(#1236、server-only) | +| `src/lib/skills/uninstall-plan.ts` | receipt 照合による remove/modified/missing/unknown/irregular 分類。drift 観測に receipt digest を含む(#1236、server-only) **Issue #3445**: target DTO に `workingTreeUnknown?: true`(不明のときだけ)。 | | `src/lib/skills/version-resolver.ts` | update 候補解決(installed より厳密に新しい exact version のみ、prerelease opt-in・range filter・互換性判定つき)。pure・client 可(#1243) | -| `src/lib/skills/update-plan.ts` | current receipt / 現行 filesystem / candidate artifact の 3-way inventory。local 変更(modified/unknown/missing/irregular)は `SKILL_UPDATE_LOCAL_CHANGES` で fail closed。security diff(risk/permissions/scripts/changelog)と risk 上昇時の追加確認契約、#1233 準拠の plan token(consume は #1244 の apply が使う)(#1243、server-only) | +| `src/lib/skills/update-plan.ts` | current receipt / 現行 filesystem / candidate artifact の 3-way inventory。local 変更(modified/unknown/missing/irregular)は `SKILL_UPDATE_LOCAL_CHANGES` で fail closed。security diff(risk/permissions/scripts/changelog)と risk 上昇時の追加確認契約、#1233 準拠の plan token(consume は #1244 の apply が使う)(#1243、server-only) **Issue #3445**: target DTO に `workingTreeUnknown?: true`(不明のときだけ)。 | | `src/lib/skills/uninstall-apply.ts` | unlink 直前の lstat/mode/digest 再照合、`rmdir(2)` のみ使用、receipt を最後に削除(#1236、server-only) | | `src/lib/skills/updater.ts` | update apply の filesystem 層。旧 payload を uninstall guard で再検査(1 件でも local 変更なら zero-write の `SKILL_UPDATE_LOCAL_CHANGES`)→ service-owned root へ digest 検証つき backup(#1245 の verified backup 契約への受け渡し境界)→ install と同じ staging primitive で新 payload を staging → root ごとに aside→publish の 2 rename で切替。**commit point は primary root の publish rename 1 点**で、その前の失敗は in-process で aside を戻して zero-write、その後の失敗は新 receipt から前方収束のみ。crash 収束(`reconcileSkillUpdateTarget`)と secondary root の前方収束(`completeSecondarySkillUpdateRoots`)も提供(#1244、server-only) | | `src/lib/db/migrations/v45-skill-installations.ts` | `skill_installations` テーブル(#1235) | @@ -946,7 +946,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/components/settings/AgentUpdatesCard.tsx` | エージェント CLI の版表示と更新カード(Issue #2069)。More 画面と `AgentInstancesPane` が**同じ 1 個**を描く(ペインが「更新が稼働セッションに何をするか」について独自の理解を持たないため)。`worktreeId` + `instances` を渡すと `sessionStatusByInstance.isRunning` を 1 回だけ読み(ポーリングしない)、更新対象ツールの稼働中インスタンスを名指しして「再起動が必要」を警告し、更新成功後にインスタンス単位の再起動ボタン(`kill-session?cliTool=&instance=`)を出す。「再起動」の実体は kill であり、session を作る API は `send` しかないので文言も「終了 → 次の送信で新しい版が起動」と書く。更新中の出力は常時ダーク島(`bg-terminal-surface`)。`variant='plain'` で Card の枠を外す | | `src/components/settings/SettingsPanel.tsx` | 設定の中身(Issue #2707)。`/more` のページ関数から切り出したもので、`SettingsPanel` が 5 セクションを従来の順(Quick Links → General → Notifications → External Apps → About)に積む。各セクション(`SettingsQuickLinksSection` / `SettingsGeneralSection` / `SettingsNotificationsSection` / `SettingsExternalAppsSection` / `SettingsAboutSection`)は個別 export=PC の設定モーダル(#2708)がカテゴリ単位で 1 つだけマウントするため(通知・外部アプリの fetch をモーダル初回描画から外せる)。見出しは 5 つとも `common.settings.sections.*`。`SettingsQuickLinksSection` の `onNavigate` はモーダルが遷移前に自分を閉じるためのもので、ページは渡さない。`DefaultAgentsSettings` 等は `@/components/settings`(この行を再 export する barrel)ではなく相対パスで import する=循環回避 | | `src/components/settings/SettingsDialog.tsx` | PC 用の設定モーダル(Issue #2708)。`ui/Modal`(`size="lg"`・タイトルは `common.settings.title`)の中に `ui/Tabs`(`orientation="vertical"`・`variant="pill"`)を置き、左の縦カテゴリ一覧(`general` / `notifications` / `externalApps` / `about`、ラベルは `common.settings.sections.*`、`tablist` の aria-label は `common.settings.categoriesLabel`)+右のペインとして #2707 のセクション部品を並べ替えて描く(5 セクション積み上げの `SettingsPanel` は `/more` 専用のまま使わない)。`about` カテゴリだけ `SettingsQuickLinksSection`(`onNavigate={close}`=遷移前に自分を閉じる)と `SettingsAboutSection` の 2 本を積む。`Modal` は閉じている間 `null` を返し、Radix は非選択の `TabsContent` を unmount するので、通知・外部アプリ・既定エージェントの fetch はモーダルを開いてそのカテゴリに入るまで走らない。`TabsList` の `flex-col items-stretch` は tailwind-merge で `TabsList` 既定の `items-center` に勝ち縦並びになる(`sticky top-0` で `Modal` 本文のスクロールに対して左列が残る)。アプリ全体で 1 つだけ `AppProviders` がマウントし、開閉は `useSettingsDialog()`。スマホは `/more` のページのまま(入口は #2709 で PC 側だけに繋ぐ) | -| `src/cli/commands/skill.ts` / `skill-guards.ts` / `skill-format.ts` | `commandmate skill` の wiring / 確認規約・typed error mapping / 表示。非TTY は `--yes` 必須、high-risk は `--ack-risk` 必須。`install` は `--version` 必須(#1237)。`reindex` は `POST /api/skills/reindex` の薄い client(#1248)。`update-plan` は plan-only で blocked 時 exit 11(#1243)。`update` は plan 表示 → 確認 → token 提示の apply で、high-risk は `--ack-risk`、risk 上昇は `--ack-risk-increase` を追加で要求する(#1244) | +| `src/cli/commands/skill.ts` / `skill-guards.ts` / `skill-format.ts` | `commandmate skill` の wiring / 確認規約・typed error mapping / 表示。非TTY は `--yes` 必須、high-risk は `--ack-risk` 必須。`install` は `--version` 必須(#1237)。`reindex` は `POST /api/skills/reindex` の薄い client(#1248)。`update-plan` は plan-only で blocked 時 exit 11(#1243)。`update` は plan 表示 → 確認 → token 提示の apply で、high-risk は `--ack-risk`、risk 上昇は `--ack-risk-increase` を追加で要求する(#1244) **Issue #3445**: `formatWorkingTreeMark(target)`=不明は ` [working tree status unknown]`、dirty は従来どおり ` [working tree dirty]`。 | | `tests/integration/skills/mvp-harness.ts` | MVP 統合 suite の共有 harness。実 git リポジトリ生成、fixture Catalog/artifact 組立、tree 差分・残留物の観測。root は `$HOME` 配下(`system-directories` が `/tmp`・`/var` を拒否するため)(#1242) | | `tests/integration/skills-update.test.ts` | update の plan→apply→receipt→discovery→audit を実 route/実 filesystem で通す gate。local 変更・stale・consumed token の zero-write(tree 差分ゼロで証明)、retry の replay(切替 1 回・audit 1 行)、audit の old→new/source と絶対 path 非混入(#1244) | | `tests/integration/skills-mvp-{install-flow,security-regression,source-integrity}.test.ts` | MVP 出荷判定 gate。層を実物のまま繋いだ E2E / 悪性 corpus 59 件の fail closed と不変条件 / wire 上の allowlist・redirect・checksum・size・offline(#1242) | @@ -1026,7 +1026,7 @@ Skill の support matrix・MVP 既知制約・rollback 手順は [docs/user-guid | `src/cli/commands/peers.ts` | peers コマンド(Issue #2376)。自分と同じリポジトリの worktree × roster を 1 回の fan-out で並べ、自分の行に `(you)`、他の行に貼れる `ask` 行を付ける。リポジトリ同一性は `repositoryPath` が権威で `repositoryName` は前史行のフォールバック、どちらも無い行は除外(他人のプロジェクトの `--instance` を差し出さない)。**Issue #2404**: 取得済みの worktree 一覧をそのまま `detectServerMismatch()` に渡す(追加 I/O 無し)。ここが空になるのは自分の worktree が一覧に無いときで、それは `No peer sessions found.`=「兄弟がいない」ではなく「別サーバを見ている」。不一致なら stderr に警告、`--json` には `serverUrl`(常時)と `serverMismatch: true`(不一致時のみ)をトップレベルに追加 —— **`self` は不変**(「誰が聞いているか」の答えに接続先は含まれない。`self` を読む既存テスト/消費側を壊さない) | | `src/cli/utils/` | preflight, env-setup, daemon, pid-manager, port-allocator, api-client, command-helpers 等 | | `src/cli/config/` | 依存関係定義、duration-constants, cli-tool-ids | -| `src/cli/types/api-responses.ts` | CLI側APIレスポンス型定義(WorktreeListResponse, CurrentOutputResponse, PromptResponseResult等)(Issue #518)。**Issue #1699**: PromptData ミラーに `instructionText`/`approvalTarget`、WaitPromptOutput に `approvalTarget`/`autoYesSuppression` を明示(`[key:string]:unknown` があっても serializer 側の whitelist は明示が要る)。**Issue #1708**: `CurrentOutputResponse` に `isUnclassifiedActive?: boolean` を追加 — サーバは #1120 以降ずっと publish していたが型が無く、CLI 側からは存在しないのと同じだった **Issue #1785**: `CurrentOutputResponse` に `model?` / `reasoningEffort?`(`string|null`)を追加。サーバは常に送るが optional にしてあるのは、CLI は自分より古い daemon を叩きうる(`npm i -g` は稼働デーモンを再起動しない)ため。`undefined` は「このサーバに機能が無い」=コマンド側で null に潰す **Issue #1926**: `CurrentOutputResponse` に `statusEvidence?: 'positive'|'none'`(`sessionStatus` と同じく**閉じた union**。第 3 の値の追加は additive ではなく破壊的変更)/`lastKnownStatus?`/`lastKnownStatusAt?`、`structuredEvents` に `turnId?`/`openedAt?`/`closedAt?`/`closedBy?`(`closedBy` は前方互換のため `string`)、`WorktreeItem.sessionStatusByCli` の各エントリに `statusEvidence?`/`sessionStatusReason?`/`lastKnownStatus?`/`lastKnownStatusAt?` を additive 追加。キーの不在は「そのサーバーが言わない」であって `'positive'` ではない。**Issue #1929**: `CurrentOutputResponse.detector?.staleness?` を追加(`Record`)。**二重に optional** で、キーごと無い=「#1929 以前のサーバ、またはキャッシュが冷えている(まだ分からない)」、`staleness` が `{}`=「probe 済みで陳腐化ゼロ」。probe が走らなかったツール(PATH に無い等)は行ごと出ない。`GET /api/capabilities` には**載せない**(インストール済み CLI の版一覧=ソフトウェアインベントリ、DR4-008)。**Issue #1930**: `structuredEvents` ミラーに `pendingDecisions[]` / `dedupDropped` / `dialogPendingMaxMs` を追加し、 `turnId` の「暫定・安定した同一性ではない」注記を撤去(安定になったため)。`closedBy` は引き続き `string` (新しい閉じ理由を出すサーバを parse 失敗にしないため、#1843)。 **Issue #2015**: `CurrentOutputResponse.statusEvidence` の docstring を #2011 後の定義に是正。「`isUnclassifiedActive` が運ぶのと同じ事実」は**誤り** — `statusEvidence` は「判定に積極的な裏付けがあるか」(検出器がツールごとに produce、§4 D1 ロールアウトで拡大)、`isUnclassifiedActive` は「どのルールもフレームを読めたか」(`isUnclassifiedFrame` = `running` かつ `no_recent_output`/`unknown_frame`/`default`)で、**答えは双方向に食い違う**(アイドル composer=`'none'` かつ classified / `Stop` 付き不可読ペイン=`'positive'` かつ unclassified)。docstring 内に機械可読なマーカー行 `Unclassified reasons:` / `Classified-but-unproven:` を置き、`tests/unit/cli/types/status-evidence-contract-2015.test.ts` が `STATUS_REASON` と union 幅に対して固定する **Issue #2038**: `OpencodeInstanceSession` / `OpencodeSessionsResponse` を追加(`GET /api/worktrees/[id]/opencode/session` のミラー)。全フィールドが nullable なのは、意味があるのが**停止中**インスタンスだから — そこでの `sessionId` は次の `send` が `opencode -s ` に渡す値で、記述してくれる稼働セッションは存在しない **Issue #2040**: `CurrentOutputResponse.structuredEvents` に `session?`(`AgentSessionRecord` のミラー。全メンバ nullable で、null は「エージェントが言わなかった」であって 0 ではない。`tokens.cacheRead` / `cacheWrite` は opencode の `tokens.cache.read` / `.write` を平坦化したもの、`total` は Session ではなく assistant message 側の宣言なので現状常に null=自前で合計しない)と `pendingDecisions[].kind?`(前方互換のため `string`)/ `questionOptions?` を additive 追加。`StructuredDecisionResult` を新設(`POST /api/worktrees/[id]/respond` の id 無し/id 付き両形のミラー。`PromptResponseResult` とは**別ルートなので別ミラー**で、違いは `resolved.via` に `'structured-question'` があり、その分岐では `optionNumber` / `optionLabel` の代わりに `answers` / `optionNumbers` / `optionLabels` / `freeText` が入ること)。#2039 が申し送っていた「`resolved.via` union に `'structured-question'` を足す」はこの新設型で解決した(`PromptResponseResult` 側は変更していない — `/prompt-response` は question を答えないため) #2042 の `structuredEvents.sessionContext` ミラーを追加(optional。`session.tokens` の和では 代替できない理由を docblock に持つ) Issue #2043: `structuredEvents.sessionDiff` と `AgentSessionDiffFile` を mirror。 `files`(そのターンが変えたもの/履歴)と `revertedFiles`(いま revert がせき止めているもの/現況)は別の問いで、 片方がもう片方の代わりにならない。`file` / `patch` / `status` が nullable なのは opencode の OpenAPI が `SnapshotFileDiff` の required を `additions` / `deletions` の 2 つしか宣言していないため **Issue #2054**: `structuredEvents.source` に `kind?` / `degradedReason?` / `liveness?` / `probedActivity?` を追加。#1924 と同じ理由で **string 型のまま**(新しい値を宣言する新しいサーバに対して古い CLI が parse 失敗するのを避ける) **Issue #2100: `PromptData` に `decisionId` / `askUserQuestion` を追加のみで宣言**(index signature 越しに既に通っていたものを型として明示)。`decisionId` は approval と question の**両方**で non-null になりうる **Issue #2101**: `InterruptResponse` / `InterruptedSession` を additive 追加(`POST /api/worktrees/[id]/interrupt` の 200 ミラー)。`cliToolId` を union でなく `string` にしてあるのは `WorktreeItem.cliToolId` と同じ理由(自分より新しい daemon の未知のツール名を parse 失敗にしない)。ルートは対象ゼロを『200 + 空配列』ではなく 404 で返すため、この型の `interrupted` が空になることはない — 空配列は CLI 側が `--json` の非ゼロ経路で組み立てる **Issue #2095**: `CurrentOutputResponse.paneObstruction?: {id, matchedText, at} \| null` を追加(`CurrentOutputPayload.paneObstruction` の mirror)。他フィールドと同じく optional=`undefined` は「このサーバは #2095 より古い」。`null` は「サイドバーが無い」ではなく「レイアウトを読めなかった」 **Issue #2061: `VerifyConfigGateView` / `VerifyConfigOptionsView` / `VerifyConfigResponse` / `VerifyConfigExclusionView` / `VerifyConfigDraftResponse` を additive 追加**(`/api/worktrees/[id]/verify/config` の GET/POST ミラー)。`mutex` / `retryOnFail` / `flakyIsPass` は**明示 null** で送る — 型側の `mutex?: string` は「ファイルが宣言しなかった」を意味するが、JSON 越しでは『キーを送り忘れたサーバ』と区別できない **Issue #2369**: `CurrentOutputResponse` に `isDismissablePanelActive?: boolean` を追加(`capture --json` に出る)。`isPagerActive` が `isSelectionListActive` の部分集合なのに対し、これは素集合であることを型ドキュメントに明記してある。 **Issue #2386**: `PromptMessageResponse` に `requestId?: string` を追加。サーバは以前から wire に出していたが、この CLI 側ミラーに宣言が無く CLI からは存在しないのと同じだった。名前空間+id の形で、名前空間が読む対象 — `-turn:`=転写リーダーが書いた=エージェント自身の言葉、`relay-sys:` / `model-changed:` = CommandMate がセッションについて書いた行、**不在**=画面のスクレイプ。`ask` が返答行を選ぶ唯一の判別子 **Issue #2586**: `sessionStatusByCli` の docコメントにあった「未集約の per-instance マップは `sessionStatusByInstance`、**CLI は読まない**」を是正。#2575 以降 `commandmate ls` の `AUTO_YES` 列が `autoYesByInstance`(#2512)と併せて読んでいるため事実と食い違っていた。ただし読んでいるのは**この共有インターフェース経由ではなく** `src/cli/commands/ls.ts` 内のローカル拡張 `LsWorktreeItem`(先例 `peers.ts` の `PeerWorktreeItem`)なので、「ここでは宣言しない」+「なぜ per-tool 集約では答えられないか(Auto-Yes は instance 単位で武装するため、どのセッションが応答を止めるかは集約から出せない)」+「2 つ目のコマンドが必要にするまで共有型は広げない」を書いた。型の変更なし(コメントのみ) **Issue #2886**: `CurrentOutputResponse` に `sessionName?: string` を追加(`CurrentOutputPayload.sessionName` の mirror)。optional なのは #2886 より古いサーバが送らないため — `undefined` は「このサーバは #2886 より古い」であって「セッションが無い」ではない。`capture --json`(`formatJson`、`fullOutput` 以外 verbatim)にはコード変更なしでそのまま載る Issue #3179: `CurrentOutputResponse.startingSince?: number \| null`。 **Issue #3184**: `CurrentOutputResponse.promptView?`、`WaitPromptOutput.answerVia?`。 **Issue #3300**: `CurrentOutputResponse` で必須なのは `isRunning` / `content` / `lineCount` だけ。動いていないセッションの応答に無い 13 欄(`autoYes` / `isPromptWaiting` / `thinking` / `thinkingMessage` / `isComplete` / `isGenerating` / `fullOutput` / `realtimeSnippet` / `lastCapturedLine` / `promptData` / `isSelectionListActive` / `lastServerResponseTimestamp` / `serverPollerActive`)は省略可。`agentMode?` / `composerText?` / `composerState?`(どれも wire の `string`)と、`AutoYesStatesResponse`(GET `/auto-yes` の `cliToolId` なしの形。CLI が読む `instances` だけ)を持つ。サーバー側の型(`CurrentOutputResponseBody`)は import グラフが 100 モジュールを超え、その数十が `@/` を使うので CLI からは import できず、写しのまま。最上位の欄の名前と必須・省略可は `tests/unit/cli/types/current-output-mirror-3300.test.ts` が固定する。 **Issue #3397**: `CurrentOutputResponse.autoYes.lastEnterFallback?`(`outcome`/`promptType`/`refusalReason` は wire の string)。`tests/unit/cli/types/enter-fallback-mirror-3397.test.ts` が実応答込みで照合。 | +| `src/cli/types/api-responses.ts` | CLI側APIレスポンス型定義(WorktreeListResponse, CurrentOutputResponse, PromptResponseResult等)(Issue #518)。**Issue #1699**: PromptData ミラーに `instructionText`/`approvalTarget`、WaitPromptOutput に `approvalTarget`/`autoYesSuppression` を明示(`[key:string]:unknown` があっても serializer 側の whitelist は明示が要る)。**Issue #1708**: `CurrentOutputResponse` に `isUnclassifiedActive?: boolean` を追加 — サーバは #1120 以降ずっと publish していたが型が無く、CLI 側からは存在しないのと同じだった **Issue #1785**: `CurrentOutputResponse` に `model?` / `reasoningEffort?`(`string|null`)を追加。サーバは常に送るが optional にしてあるのは、CLI は自分より古い daemon を叩きうる(`npm i -g` は稼働デーモンを再起動しない)ため。`undefined` は「このサーバに機能が無い」=コマンド側で null に潰す **Issue #1926**: `CurrentOutputResponse` に `statusEvidence?: 'positive'|'none'`(`sessionStatus` と同じく**閉じた union**。第 3 の値の追加は additive ではなく破壊的変更)/`lastKnownStatus?`/`lastKnownStatusAt?`、`structuredEvents` に `turnId?`/`openedAt?`/`closedAt?`/`closedBy?`(`closedBy` は前方互換のため `string`)、`WorktreeItem.sessionStatusByCli` の各エントリに `statusEvidence?`/`sessionStatusReason?`/`lastKnownStatus?`/`lastKnownStatusAt?` を additive 追加。キーの不在は「そのサーバーが言わない」であって `'positive'` ではない。**Issue #1929**: `CurrentOutputResponse.detector?.staleness?` を追加(`Record`)。**二重に optional** で、キーごと無い=「#1929 以前のサーバ、またはキャッシュが冷えている(まだ分からない)」、`staleness` が `{}`=「probe 済みで陳腐化ゼロ」。probe が走らなかったツール(PATH に無い等)は行ごと出ない。`GET /api/capabilities` には**載せない**(インストール済み CLI の版一覧=ソフトウェアインベントリ、DR4-008)。**Issue #1930**: `structuredEvents` ミラーに `pendingDecisions[]` / `dedupDropped` / `dialogPendingMaxMs` を追加し、 `turnId` の「暫定・安定した同一性ではない」注記を撤去(安定になったため)。`closedBy` は引き続き `string` (新しい閉じ理由を出すサーバを parse 失敗にしないため、#1843)。 **Issue #2015**: `CurrentOutputResponse.statusEvidence` の docstring を #2011 後の定義に是正。「`isUnclassifiedActive` が運ぶのと同じ事実」は**誤り** — `statusEvidence` は「判定に積極的な裏付けがあるか」(検出器がツールごとに produce、§4 D1 ロールアウトで拡大)、`isUnclassifiedActive` は「どのルールもフレームを読めたか」(`isUnclassifiedFrame` = `running` かつ `no_recent_output`/`unknown_frame`/`default`)で、**答えは双方向に食い違う**(アイドル composer=`'none'` かつ classified / `Stop` 付き不可読ペイン=`'positive'` かつ unclassified)。docstring 内に機械可読なマーカー行 `Unclassified reasons:` / `Classified-but-unproven:` を置き、`tests/unit/cli/types/status-evidence-contract-2015.test.ts` が `STATUS_REASON` と union 幅に対して固定する **Issue #2038**: `OpencodeInstanceSession` / `OpencodeSessionsResponse` を追加(`GET /api/worktrees/[id]/opencode/session` のミラー)。全フィールドが nullable なのは、意味があるのが**停止中**インスタンスだから — そこでの `sessionId` は次の `send` が `opencode -s ` に渡す値で、記述してくれる稼働セッションは存在しない **Issue #2040**: `CurrentOutputResponse.structuredEvents` に `session?`(`AgentSessionRecord` のミラー。全メンバ nullable で、null は「エージェントが言わなかった」であって 0 ではない。`tokens.cacheRead` / `cacheWrite` は opencode の `tokens.cache.read` / `.write` を平坦化したもの、`total` は Session ではなく assistant message 側の宣言なので現状常に null=自前で合計しない)と `pendingDecisions[].kind?`(前方互換のため `string`)/ `questionOptions?` を additive 追加。`StructuredDecisionResult` を新設(`POST /api/worktrees/[id]/respond` の id 無し/id 付き両形のミラー。`PromptResponseResult` とは**別ルートなので別ミラー**で、違いは `resolved.via` に `'structured-question'` があり、その分岐では `optionNumber` / `optionLabel` の代わりに `answers` / `optionNumbers` / `optionLabels` / `freeText` が入ること)。#2039 が申し送っていた「`resolved.via` union に `'structured-question'` を足す」はこの新設型で解決した(`PromptResponseResult` 側は変更していない — `/prompt-response` は question を答えないため) #2042 の `structuredEvents.sessionContext` ミラーを追加(optional。`session.tokens` の和では 代替できない理由を docblock に持つ) Issue #2043: `structuredEvents.sessionDiff` と `AgentSessionDiffFile` を mirror。 `files`(そのターンが変えたもの/履歴)と `revertedFiles`(いま revert がせき止めているもの/現況)は別の問いで、 片方がもう片方の代わりにならない。`file` / `patch` / `status` が nullable なのは opencode の OpenAPI が `SnapshotFileDiff` の required を `additions` / `deletions` の 2 つしか宣言していないため **Issue #2054**: `structuredEvents.source` に `kind?` / `degradedReason?` / `liveness?` / `probedActivity?` を追加。#1924 と同じ理由で **string 型のまま**(新しい値を宣言する新しいサーバに対して古い CLI が parse 失敗するのを避ける) **Issue #2100: `PromptData` に `decisionId` / `askUserQuestion` を追加のみで宣言**(index signature 越しに既に通っていたものを型として明示)。`decisionId` は approval と question の**両方**で non-null になりうる **Issue #2101**: `InterruptResponse` / `InterruptedSession` を additive 追加(`POST /api/worktrees/[id]/interrupt` の 200 ミラー)。`cliToolId` を union でなく `string` にしてあるのは `WorktreeItem.cliToolId` と同じ理由(自分より新しい daemon の未知のツール名を parse 失敗にしない)。ルートは対象ゼロを『200 + 空配列』ではなく 404 で返すため、この型の `interrupted` が空になることはない — 空配列は CLI 側が `--json` の非ゼロ経路で組み立てる **Issue #2095**: `CurrentOutputResponse.paneObstruction?: {id, matchedText, at} \| null` を追加(`CurrentOutputPayload.paneObstruction` の mirror)。他フィールドと同じく optional=`undefined` は「このサーバは #2095 より古い」。`null` は「サイドバーが無い」ではなく「レイアウトを読めなかった」 **Issue #2061: `VerifyConfigGateView` / `VerifyConfigOptionsView` / `VerifyConfigResponse` / `VerifyConfigExclusionView` / `VerifyConfigDraftResponse` を additive 追加**(`/api/worktrees/[id]/verify/config` の GET/POST ミラー)。`mutex` / `retryOnFail` / `flakyIsPass` は**明示 null** で送る — 型側の `mutex?: string` は「ファイルが宣言しなかった」を意味するが、JSON 越しでは『キーを送り忘れたサーバ』と区別できない **Issue #2369**: `CurrentOutputResponse` に `isDismissablePanelActive?: boolean` を追加(`capture --json` に出る)。`isPagerActive` が `isSelectionListActive` の部分集合なのに対し、これは素集合であることを型ドキュメントに明記してある。 **Issue #2386**: `PromptMessageResponse` に `requestId?: string` を追加。サーバは以前から wire に出していたが、この CLI 側ミラーに宣言が無く CLI からは存在しないのと同じだった。名前空間+id の形で、名前空間が読む対象 — `-turn:`=転写リーダーが書いた=エージェント自身の言葉、`relay-sys:` / `model-changed:` = CommandMate がセッションについて書いた行、**不在**=画面のスクレイプ。`ask` が返答行を選ぶ唯一の判別子 **Issue #2586**: `sessionStatusByCli` の docコメントにあった「未集約の per-instance マップは `sessionStatusByInstance`、**CLI は読まない**」を是正。#2575 以降 `commandmate ls` の `AUTO_YES` 列が `autoYesByInstance`(#2512)と併せて読んでいるため事実と食い違っていた。ただし読んでいるのは**この共有インターフェース経由ではなく** `src/cli/commands/ls.ts` 内のローカル拡張 `LsWorktreeItem`(先例 `peers.ts` の `PeerWorktreeItem`)なので、「ここでは宣言しない」+「なぜ per-tool 集約では答えられないか(Auto-Yes は instance 単位で武装するため、どのセッションが応答を止めるかは集約から出せない)」+「2 つ目のコマンドが必要にするまで共有型は広げない」を書いた。型の変更なし(コメントのみ) **Issue #2886**: `CurrentOutputResponse` に `sessionName?: string` を追加(`CurrentOutputPayload.sessionName` の mirror)。optional なのは #2886 より古いサーバが送らないため — `undefined` は「このサーバは #2886 より古い」であって「セッションが無い」ではない。`capture --json`(`formatJson`、`fullOutput` 以外 verbatim)にはコード変更なしでそのまま載る Issue #3179: `CurrentOutputResponse.startingSince?: number \| null`。 **Issue #3184**: `CurrentOutputResponse.promptView?`、`WaitPromptOutput.answerVia?`。 **Issue #3300**: `CurrentOutputResponse` で必須なのは `isRunning` / `content` / `lineCount` だけ。動いていないセッションの応答に無い 13 欄(`autoYes` / `isPromptWaiting` / `thinking` / `thinkingMessage` / `isComplete` / `isGenerating` / `fullOutput` / `realtimeSnippet` / `lastCapturedLine` / `promptData` / `isSelectionListActive` / `lastServerResponseTimestamp` / `serverPollerActive`)は省略可。`agentMode?` / `composerText?` / `composerState?`(どれも wire の `string`)と、`AutoYesStatesResponse`(GET `/auto-yes` の `cliToolId` なしの形。CLI が読む `instances` だけ)を持つ。サーバー側の型(`CurrentOutputResponseBody`)は import グラフが 100 モジュールを超え、その数十が `@/` を使うので CLI からは import できず、写しのまま。最上位の欄の名前と必須・省略可は `tests/unit/cli/types/current-output-mirror-3300.test.ts` が固定する。 **Issue #3397**: `CurrentOutputResponse.autoYes.lastEnterFallback?`(`outcome`/`promptType`/`refusalReason` は wire の string)。`tests/unit/cli/types/enter-fallback-mirror-3397.test.ts` が実応答込みで照合。 **Issue #3445**: skill install / update / uninstall plan の `target.workingTreeUnknown?: boolean`。 | | `tests/unit/cli/types/status-evidence-contract-2015.test.ts` | `statusEvidence` docstring と検出器語彙の対応 guard(Issue #2015)。docstring のマーカー行が名指しする reason を `STATUS_REASON` と突き合わせ(`input_prompt` が unclassified 側へ戻ったら赤)、CLI の union 幅をサーバの `StatusEvidence` と双方向 assignability で固定(第 3 の値を片側だけに足すと `tsc --noEmit` が落ちる)。**証明できない範囲を明記**: 権威は `src/lib/session/status-evidence.ts` の `UNCLASSIFIED_FRAME_REASONS` だが #2011 で module-private のため、集合への**追加**は検出できない | | `tests/unit/cli/types/current-output-mirror-3300.test.ts` | `CurrentOutputResponse`(CLI の写し)と `CurrentOutputResponseBody`(サーバー)の最上位の欄の guard(Issue #3300)。TypeScript の `Program`(ルート tsconfig と同じ `@/` 解決)で 2 つの型を読み、`getPropertiesOfType` で名前と省略可を比べる。規則は 3 つ: 名前の集合が同じ/サーバーが省略しうる欄は写しでも省略可(例外なし)/サーバーが必ず送る欄を写しが省略可にするのは `OLDER_DAEMON_FIELDS`(理由つき、17 欄)に載せたものだけ。一覧の側も型に合わせて検査する(差が無くなった欄が残ると落ちる)。陽性対照は、構文木の位置でソースを書き換えた版を同じ読み手に通す(片方にだけ欄を足す・13 欄を 1 つずつ必須に戻す・片方だけ必須/省略可にする) | | `src/cli/utils/qr-terminal.ts` | ターミナル QR レンダラ(Issue #1937 R8、U-1 判定)。`renderQrToTerminal(text, opts)` / `formatQrForTerminal()` / `QR_QUIET_ZONE_MODULES`=4 / `DEFAULT_TERMINAL_COLUMNS`=80。依存は **`qr.js@0.0.0` を直接依存に昇格**(`react-qr-code` の固定依存として既に lock にあり増分 0 バイト。`qrcode` は +5,424 KB・+31 パッケージ)。**package facade ではなく `qr.js/lib/QRCode` を `createRequire` で読む** — facade の `opt.errorCorrectLevel \|\| ErrorCorrectLevel.H` は M(数値 0)を黙って H に化けさせ、`qr.js` は型定義を持たないので素の `import` は `build:cli` が TS7016 で落ちるため。上半ブロックで 2 モジュール行を 1 行に畳み、115 文字の URL が level M で 45 モジュール=53 桁。**幅に収まらないときは何も出さず `fits:false`**(折り返された QR は読めないのに出力できているように見えるため)で、ECC は 1 段下げて再試行する一方 quiet zone は削らない **Issue #1937 R9**: 呼び出し元は `remote up` の `announcePairing()`。`formatQrForTerminal()` が `null`(幅に収まらない)を返したときだけ URL をテキストで出す — QR が出せた場合に URL を併記しないのは、その URL がペアリングコードを含む一度限りの資格情報だから。 |