diff --git a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift index 7b764169..801a1f6d 100644 --- a/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift +++ b/supacode/Domain/AgentDetection/AgentScreenSnapshot.swift @@ -3,6 +3,10 @@ struct AgentScreenSnapshot: Equatable, Sendable { let lines: [String] nonisolated init(canonicalText: String) { + assert( + canonicalText == agentDetectionRecentText(canonicalText), + "AgentScreenSnapshot requires canonical detector-tail text." + ) self.text = canonicalText self.lines = canonicalText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) } diff --git a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift new file mode 100644 index 00000000..dc398265 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift @@ -0,0 +1,216 @@ +import Foundation + +enum ClaudeScreenProfile { + enum RuleID { + nonisolated static let viewer = AgentScreenRuleID("claude.viewer") + nonisolated static let blockedPrompt = AgentScreenRuleID("claude.blockedPrompt") + nonisolated static let spinner = AgentScreenRuleID("claude.spinner") + nonisolated static let elapsedStatus = AgentScreenRuleID("claude.elapsedStatus") + nonisolated static let backgroundWork = AgentScreenRuleID("claude.backgroundWork") + nonisolated static let idleComposer = AgentScreenRuleID("claude.idleComposer") + + // Keep exhaustive so prefix and uniqueness tests cover every emitted ID. + nonisolated static let all = [ + viewer, + blockedPrompt, + spinner, + elapsedStatus, + backgroundWork, + idleComposer, + ] + } + + nonisolated static func detect(in snapshot: AgentScreenSnapshot) -> AgentScreenDetection { + let regions = ClaudeScreenRegions(snapshot: snapshot) + + if hasViewerChrome(regions) { + return AgentScreenDetection(state: .unknown, reason: .matched(RuleID.viewer)) + } + if hasBlockedPrompt(regions) { + return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.blockedPrompt)) + } + if hasSpinnerActivity(regions.liveStatus) { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.spinner)) + } + if hasElapsedStatusLine(regions.liveStatus) { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.elapsedStatus)) + } + if regions.belowPromptLower.contains("agents done") { + return AgentScreenDetection(state: .working, reason: .matched(RuleID.backgroundWork)) + } + if regions.hasIdleComposer { + return AgentScreenDetection(state: .idle, reason: .matched(RuleID.idleComposer)) + } + return AgentScreenDetection(state: .idle, reason: .noRuleMatched) + } + + nonisolated private static func hasViewerChrome(_ regions: ClaudeScreenRegions) -> Bool { + if regions.bottomChromeLines.contains(where: { line in + line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") + }) { + return true + } + + let lower = regions.bottomViewerLines.joined(separator: "\n").lowercased() + return lower.contains("⌕ filter history") + && lower.contains("↑/↓ to nav") + && lower.contains("enter to use") + && lower.contains("esc to cancel") + && lower.contains("ctrl+s to scope") + } + + nonisolated private static func hasBlockedPrompt(_ regions: ClaudeScreenRegions) -> Bool { + let lower = regions.currentInteractionLower + if lower.contains("do you want to proceed?") + || lower.contains("would you like to proceed?") + || lower.contains("waiting for permission") + || lower.contains("do you want to allow this connection?") + || lower.contains("tab to amend") + || lower.contains("ctrl+e to explain") + || lower.contains("chat about this") + || lower.contains("review your answers") + || lower.contains("skip interview and plan immediately") + { + return true + } + return hasConfirmationPrompt(lower) + || (hasSelectionPrompt(regions.currentInteractionLines) + && hasYesNoChoice(regions.currentInteractionLines)) + } + + nonisolated private static func hasSelectionPrompt(_ lines: [String]) -> Bool { + lines.contains(where: isClaudeNumberedSelectionLine) + } + + nonisolated private static func hasYesNoChoice(_ lines: [String]) -> Bool { + lines.contains { line in + let line = line.trimmingCharacters(in: .whitespaces) + let option = + line.hasPrefix("❯") + ? String(line.dropFirst()).trimmingCharacters(in: .whitespaces) + : line + let trimmed = option.lowercased() + return trimmed == "yes" + || trimmed == "no" + || trimmed.hasPrefix("1. yes") + || trimmed.hasPrefix("2. no") + || trimmed.hasPrefix("yes, and ") + || trimmed.hasPrefix("no, and tell claude") + } + } + + nonisolated private static func hasElapsedStatusLine(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "●" else { return false } + let body = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + guard let open = body.firstIndex(of: "(") else { return false } + + let label = body[.. [String] { + guard let promptIndex else { + return Array(screenLines.suffix(18)) + } + guard isClaudeNumberedSelectionLine(screenLines[promptIndex]) else { + return [] + } + let lowerBound = max(screenLines.startIndex, promptIndex - 10) + return Array(screenLines[lowerBound.. String { + guard let promptIndex else { + return screenLines.joined(separator: "\n") + } + let borderIndex = screenLines[.. String { + guard let promptIndex else { return "" } + let startIndex = screenLines.index(after: promptIndex) + guard startIndex < screenLines.endIndex else { return "" } + return screenLines[startIndex...].joined(separator: "\n") + } + + nonisolated private static func hasIdleComposer( + screenLines: [String], + promptIndex: Int? + ) -> Bool { + guard let promptIndex, promptIndex > screenLines.startIndex else { return false } + let nextIndex = screenLines.index(after: promptIndex) + guard nextIndex < screenLines.endIndex else { return false } + return isBoxBorderLine(screenLines[screenLines.index(before: promptIndex)]) + && isBoxBorderLine(screenLines[nextIndex]) + } +} + +nonisolated private func isClaudeNumberedSelectionLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "❯" else { return false } + let option = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + return isNumberedChoice(option) +} + +nonisolated private func isBoxBorderLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 3 else { return false } + return trimmed.allSatisfy { $0 == "─" || $0 == "-" } +} diff --git a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift index 894afa52..4d4d7ea0 100644 --- a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift @@ -9,6 +9,7 @@ enum CodexScreenProfile { nonisolated static let confirmationChoices = AgentScreenRuleID("codex.confirmationChoices") nonisolated static let workingFooter = AgentScreenRuleID("codex.workingFooter") + // Keep exhaustive so prefix and uniqueness tests cover every emitted ID. nonisolated static let all = [ directoryTrust, hookReview, diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index c294008a..571cdb14 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -471,7 +471,7 @@ nonisolated private func hasKimiToolSpinner(content: String, lower: String) -> B } } -nonisolated private func hasConfirmationPrompt(_ lower: String) -> Bool { +nonisolated func hasConfirmationPrompt(_ lower: String) -> Bool { guard let range = lower.range(of: "do you want") ?? lower.range(of: "would you like") else { @@ -487,7 +487,7 @@ nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { || (lower.contains("esc") && lower.contains("interrupt")) } -nonisolated private func hasSpinnerActivity(_ content: String) -> Bool { +nonisolated func hasSpinnerActivity(_ content: String) -> Bool { let spinnerScalars: Set = [ "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "❀", "❁", "❂", "❃", "❇", "❈", "❉", "❊", "❋", "✢", "✣", "✤", "✥", "✦", "✧", "✨", diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift new file mode 100644 index 00000000..aa2107fe --- /dev/null +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -0,0 +1,122 @@ +import Testing + +@testable import supacode + +struct ClaudeScreenProfileTests { + @Test func ruleIDsAreUniqueAndRuntimePrefixed() { + let ruleIDs = ClaudeScreenProfile.RuleID.all + + #expect(Set(ruleIDs).count == ruleIDs.count) + #expect(ruleIDs.allSatisfy { $0.rawValue.hasPrefix("claude.") }) + } + + @Test func capturedFixturesHaveStableReasonsIncludingViewerFix() throws { + let expectedReasons: [String: AgentScreenDetectionReason] = [ + "claude/2.1.223/blocked/command-permission.txt": .matched( + ClaudeScreenProfile.RuleID.blockedPrompt + ), + "claude/2.1.223/blocked/workspace-trust.txt": .matched( + ClaudeScreenProfile.RuleID.blockedPrompt + ), + "claude/2.1.223/idle/composer.txt": .matched(ClaudeScreenProfile.RuleID.idleComposer), + "claude/2.1.223/idle/quoted-permission.txt": .matched( + ClaudeScreenProfile.RuleID.idleComposer + ), + "claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt": .matched( + ClaudeScreenProfile.RuleID.viewer + ), + "claude/2.1.223/working/backgrounded-subagent.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + "claude/2.1.223/working/foreground-spinner.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + "claude/2.1.223/working/subagent-active.txt": .matched( + ClaudeScreenProfile.RuleID.spinner + ), + ] + let fixtures = try AgentScreenFixtureCorpus.load().filter { $0.agent == .claude } + + #expect(fixtures.count == expectedReasons.count) + for fixture in fixtures { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: fixture.text) + ) + #expect(detection.state == fixture.expectedState) + #expect(detection.reason == expectedReasons[fixture.relativePath]) + } + } + + @Test func elapsedAndBackgroundWorkHaveDistinctReasons() { + let elapsed = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + ● Forging… (10s · thinking with high effort) + ───────── + ❯ + ───────── + """ + ) + ) + #expect(elapsed.state == .working) + #expect(elapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) + + let background = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + Task complete. + ───────── + ❯ + ───────── + ◯ scout Map idle detection 3/5 agents done · 7m 29s + """ + ) + ) + #expect(background.state == .working) + #expect(background.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork)) + } + + @Test func blockerOutranksRetainedSpinner() { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot( + canonicalText: """ + ✻ Tempering… (12s · esc to interrupt) + Do you want to proceed? + ❯ 1. Yes + 2. No + Esc to cancel · Tab to amend + """ + ) + ) + + #expect(detection.state == .blocked) + #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.blockedPrompt)) + } + + @Test func unstructuredScreenUsesExplicitFallback() { + let detection = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: "screen without live Claude chrome") + ) + + #expect(detection.state == .idle) + #expect(detection.reason == .noRuleMatched) + } + + /// Temporary migration harness. The one quarantined viewer is the only + /// capture-backed intentional difference; all other fixtures stay in parity. + @Test func capturedCorpusMatchesLegacyExceptViewerFix() throws { + for fixture in try AgentScreenFixtureCorpus.load() where fixture.agent == .claude { + let legacy = DetectedAgent.claude.detectState(in: fixture.text) + let profile = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: fixture.text) + ) + + if fixture.isQuarantined { + #expect(legacy == fixture.currentState) + #expect(profile.state == fixture.expectedState) + } else { + #expect(profile.state == legacy) + } + } + } +} diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 12707bbd..61441937 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -97,7 +97,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetection() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Reading file ✽ Tempering… @@ -108,7 +108,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -119,7 +119,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Task complete. ───────── @@ -132,7 +132,7 @@ struct ScreenHeuristicsTests { @Test func claudeCurrentTrustAndSubagentScreensRemainClassified() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Accessing workspace: @@ -147,7 +147,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✢ Manifesting… (5s · ↓ 180 tokens · thought for 1s) ───────── @@ -162,7 +162,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptNearCurrentIdlePrompt() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -191,7 +191,7 @@ struct ScreenHeuristicsTests { @Test func claudeShortCompletedResponseQuestionBeforeIdlePromptIsIdle() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The completed response explains: Do you want to proceed? ───────── @@ -202,7 +202,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ❯ Quote the phrase Do you want to proceed? ───────── @@ -213,7 +213,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptOutsideRecentTail() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Do you want to proceed? ❯ 1. Yes @@ -254,7 +254,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetectsBlockedWhenFirstOptionSelectedInLongMenu() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ 需要决策:/release 跳进去发现 APK 没有链时,怎么走接? @@ -282,7 +282,7 @@ struct ScreenHeuristicsTests { @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✻ Crunched for 10s @@ -313,7 +313,7 @@ struct ScreenHeuristicsTests { @Test func claudeViewerChromeAtBottomCarriesNoSignal() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ✻ Tempering… (12s · esc to interrupt) older transcript content @@ -322,7 +322,7 @@ struct ScreenHeuristicsTests { ) == .unknown ) #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Task complete. ⌕ Search… @@ -336,7 +336,7 @@ struct ScreenHeuristicsTests { // Regression: a chat message quoting "ctrl+r to toggle" used to force // idle while the spinner below showed Claude still working. #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ 收尾完成,现状如下: @@ -363,7 +363,7 @@ struct ScreenHeuristicsTests { ] for statusRow in statusRows { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ \(statusRow) ───────── @@ -377,7 +377,7 @@ struct ScreenHeuristicsTests { @Test func claudeQuotedInterruptHintInIdleResponseIsIdle() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The live status row includes the phrase "esc to interrupt". ───────── @@ -391,7 +391,7 @@ struct ScreenHeuristicsTests { @Test func claudeElapsedStatusLineIsScopedAndRequiresACompleteToken() { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ● Forging… (10s · thinking with high effort) ───────── @@ -407,7 +407,7 @@ struct ScreenHeuristicsTests { ] for statusRow in invalidRows { #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ \(statusRow) ───────── @@ -419,7 +419,7 @@ struct ScreenHeuristicsTests { } #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ● Retrying… (10s · thinking with high effort) Completed line 1 @@ -438,7 +438,7 @@ struct ScreenHeuristicsTests { // interrupt" above the prompt), but Claude keeps a status line BELOW the // input box. The "/ agents done" segment marks active work. #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ Kicked off the scout workflow in the background. ───────── @@ -450,7 +450,7 @@ struct ScreenHeuristicsTests { ) // Idle with an ordinary footer (no workflow line) stays idle. #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ Task complete. ───────── @@ -463,7 +463,7 @@ struct ScreenHeuristicsTests { // The marker quoted in conversation (above the prompt) must NOT force // working — the check is anchored to the below-prompt footer. #expect( - DetectedAgent.claude.detectState( + claudeLegacyParityState( in: """ ⏺ The run showed 3/5 agents done before it wrapped up. ───────── @@ -475,6 +475,15 @@ struct ScreenHeuristicsTests { ) } + private func claudeLegacyParityState(in screen: String) -> AgentRawState { + let legacy = DetectedAgent.claude.detectState(in: screen) + let profile = ClaudeScreenProfile.detect( + in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(screen)) + ) + #expect(profile.state == legacy) + return legacy + } + @Test func codexDetection() { #expect( codexProfileState( -- 2.51.2 From a8549eb65b8ea172daf15b09c785cbcd3a4250c4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:24:26 +0900 Subject: [PATCH 2/4] Route Claude detection through typed profile --- .../AgentDetection/ScreenHeuristics.swift | 160 +----------------- supacodeTests/AgentScreenDetectionTests.swift | 2 +- supacodeTests/ClaudeScreenProfileTests.swift | 24 +-- .../676-history-search-viewer.metadata.json | 2 +- .../676-history-search-viewer.txt | 0 supacodeTests/ScreenHeuristicsTests.swift | 55 +++--- 6 files changed, 31 insertions(+), 212 deletions(-) rename supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/{known-misdetection/unknown/idle => unknown}/676-history-search-viewer.metadata.json (91%) rename supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/{known-misdetection/unknown/idle => unknown}/676-history-search-viewer.txt (100%) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 571cdb14..33c2f55e 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -16,7 +16,7 @@ extension DetectedAgent { case .omp: state = detectOMP(text) case .claude: - state = detectClaude(text) + return ClaudeScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) case .codex: return CodexScreenProfile.detect(in: AgentScreenSnapshot(canonicalText: text)) case .gemini: @@ -131,25 +131,6 @@ nonisolated private func hasOMPBrailleSpinner(_ line: String) -> Bool { && rest.contains(where: \.isLetter) } -nonisolated private func detectClaude(_ content: String) -> AgentRawState { - if hasClaudeViewerChrome(content) { - return .unknown - } - let currentInteraction = claudeCurrentInteractionRegion(content) - if hasClaudeBlockedPrompt(content: currentInteraction, lower: currentInteraction.lowercased()) { - return .blocked - } - - let liveStatus = agentDetectionRecentLines(contentAbovePromptBox(content), limit: 3) - if hasSpinnerActivity(liveStatus) || hasClaudeElapsedStatusLine(liveStatus) { - return .working - } - if hasClaudeBackgroundWork(content) { - return .working - } - return .idle -} - nonisolated private func detectGemini(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") @@ -291,83 +272,6 @@ nonisolated private func detectAmp(_ content: String) -> AgentRawState { return .idle } -// Transcript (ctrl+r/ctrl+o) and history-search views cover the live status -// area, so a frame showing their chrome carries no task-state signal — the -// caller maps `.unknown` to "keep the previous state". The hint strings are -// only trusted on the bottom chrome lines: matching them anywhere on screen -// misreads conversation text that merely quotes them (e.g. a discussion -// about these very heuristics). -nonisolated private func hasClaudeViewerChrome(_ content: String) -> Bool { - let bottomLines = content.split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - .suffix(3) - return bottomLines.contains { line in - line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") - } -} - -nonisolated private func contentAbovePromptBox(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return content - } - let borderIndex = lines[.. Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.count >= 3 else { return false } - return trimmed.allSatisfy { $0 == "─" || $0 == "-" } -} - -// Everything rendered AFTER the input prompt line — i.e. the footer area where -// Claude shows its persistent background-work / workflow status. Mirrors -// `contentAbovePromptBox` so the background-work check can stay anchored to the -// footer and never trip on transcript text that merely mentions the marker. -nonisolated private func contentBelowPromptBox(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return "" - } - let startIndex = lines.index(after: promptIndex) - guard startIndex < lines.endIndex else { return "" } - return lines[startIndex...].joined(separator: "\n") -} - -// While a background workflow runs, Claude's turn has ended (so the spinner and -// "esc to interrupt" hint above the prompt are gone) but it keeps a persistent -// status line BELOW the input box, e.g. -// "◯ my-workflow 3/5 agents done · 7m 29s · ↓ 288.5k tokens" -// The "/ agents done" segment is Claude's stable statusText -// template, so "agents done" is a distinctive marker. Anchored to the -// below-prompt footer so it can't be tripped by conversation text. -nonisolated private func hasClaudeBackgroundWork(_ content: String) -> Bool { - contentBelowPromptBox(content).lowercased().contains("agents done") -} - -nonisolated private func claudeCurrentInteractionRegion(_ content: String) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { - return lines.suffix(18).joined(separator: "\n") - } - guard isClaudeNumberedSelectionLine(lines[promptIndex]) else { - return "" - } - - let lowerBound = max(lines.startIndex, promptIndex - 10) - return lines[lowerBound.. Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "❯" else { return false } - let option = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) - return isNumberedChoice(option) -} - nonisolated func isNumberedChoice(_ option: String) -> Bool { guard let firstToken = option.split(whereSeparator: { $0.isWhitespace }).first, firstToken.last == "." @@ -378,44 +282,6 @@ nonisolated func isNumberedChoice(_ option: String) -> Bool { return !number.isEmpty && number.allSatisfy(\.isNumber) } -nonisolated private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { - if lower.contains("do you want to proceed?") - || lower.contains("would you like to proceed?") - || lower.contains("waiting for permission") - || lower.contains("do you want to allow this connection?") - || lower.contains("tab to amend") - || lower.contains("ctrl+e to explain") - || lower.contains("chat about this") - || lower.contains("review your answers") - || lower.contains("skip interview and plan immediately") - { - return true - } - return hasConfirmationPrompt(lower) - || (hasClaudeSelectionPrompt(content) && hasClaudeYesNoChoice(content)) -} - -nonisolated private func hasClaudeSelectionPrompt(_ content: String) -> Bool { - content.split(separator: "\n").contains { isClaudeNumberedSelectionLine(String($0)) } -} - -nonisolated private func hasClaudeYesNoChoice(_ content: String) -> Bool { - content.split(separator: "\n").contains { line in - let line = line.trimmingCharacters(in: .whitespaces) - let option = - line.hasPrefix("❯") - ? String(line.dropFirst()).trimmingCharacters(in: .whitespaces) - : line - let trimmed = option.lowercased() - return trimmed == "yes" - || trimmed == "no" - || trimmed.hasPrefix("1. yes") - || trimmed.hasPrefix("2. no") - || trimmed.hasPrefix("yes, and ") - || trimmed.hasPrefix("no, and tell claude") - } -} - nonisolated private func hasCursorPermissionPrompt(content: String, lower: String) -> Bool { if lower.contains("(y) (enter)") { return true @@ -504,30 +370,6 @@ nonisolated func hasSpinnerActivity(_ content: String) -> Bool { } } -nonisolated private func hasClaudeElapsedStatusLine(_ content: String) -> Bool { - content.split(separator: "\n").contains { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.first == "●" else { return false } - let body = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) - guard let open = body.firstIndex(of: "(") else { return false } - - let label = body[.. Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() diff --git a/supacodeTests/AgentScreenDetectionTests.swift b/supacodeTests/AgentScreenDetectionTests.swift index 689e6499..e89092cc 100644 --- a/supacodeTests/AgentScreenDetectionTests.swift +++ b/supacodeTests/AgentScreenDetectionTests.swift @@ -7,7 +7,7 @@ struct AgentScreenDetectionTests { @Test func unmigratedDetectorsReturnTheirExistingStateWithAStableReason() { let screen = "screen without a live rule" - for agent in DetectedAgent.allCases where agent != .codex { + for agent in DetectedAgent.allCases where agent != .codex && agent != .claude { let detection = agent.detectScreen(in: screen) #expect(detection.state == agent.detectState(in: screen)) diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift index aa2107fe..5ed61562 100644 --- a/supacodeTests/ClaudeScreenProfileTests.swift +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -22,7 +22,7 @@ struct ClaudeScreenProfileTests { "claude/2.1.223/idle/quoted-permission.txt": .matched( ClaudeScreenProfile.RuleID.idleComposer ), - "claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt": .matched( + "claude/2.1.223/unknown/676-history-search-viewer.txt": .matched( ClaudeScreenProfile.RuleID.viewer ), "claude/2.1.223/working/backgrounded-subagent.txt": .matched( @@ -39,9 +39,7 @@ struct ClaudeScreenProfileTests { #expect(fixtures.count == expectedReasons.count) for fixture in fixtures { - let detection = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) + let detection = DetectedAgent.claude.detectScreen(in: fixture.text) #expect(detection.state == fixture.expectedState) #expect(detection.reason == expectedReasons[fixture.relativePath]) } @@ -101,22 +99,4 @@ struct ClaudeScreenProfileTests { #expect(detection.state == .idle) #expect(detection.reason == .noRuleMatched) } - - /// Temporary migration harness. The one quarantined viewer is the only - /// capture-backed intentional difference; all other fixtures stay in parity. - @Test func capturedCorpusMatchesLegacyExceptViewerFix() throws { - for fixture in try AgentScreenFixtureCorpus.load() where fixture.agent == .claude { - let legacy = DetectedAgent.claude.detectState(in: fixture.text) - let profile = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: fixture.text) - ) - - if fixture.isQuarantined { - #expect(legacy == fixture.currentState) - #expect(profile.state == fixture.expectedState) - } else { - #expect(profile.state == legacy) - } - } - } } diff --git a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json similarity index 91% rename from supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json rename to supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json index 0ff9c7eb..b31054bd 100644 --- a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.metadata.json +++ b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.metadata.json @@ -15,5 +15,5 @@ "prompt history entries replaced with ", "terminal right-edge padding removed without changing line wrapping" ], - "issue": "https://github.com/onevcat/Prowl/issues/676" + "issue": null } diff --git a/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt b/supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.txt similarity index 100% rename from supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/known-misdetection/unknown/idle/676-history-search-viewer.txt rename to supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.223/unknown/676-history-search-viewer.txt diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 61441937..04b283c2 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -97,7 +97,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetection() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Reading file ✽ Tempering… @@ -108,7 +108,7 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -119,7 +119,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ───────── @@ -132,7 +132,7 @@ struct ScreenHeuristicsTests { @Test func claudeCurrentTrustAndSubagentScreensRemainClassified() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Accessing workspace: @@ -147,7 +147,7 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✢ Manifesting… (5s · ↓ 180 tokens · thought for 1s) ───────── @@ -162,7 +162,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptNearCurrentIdlePrompt() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -191,7 +191,7 @@ struct ScreenHeuristicsTests { @Test func claudeShortCompletedResponseQuestionBeforeIdlePromptIsIdle() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The completed response explains: Do you want to proceed? ───────── @@ -202,7 +202,7 @@ struct ScreenHeuristicsTests { ) == .idle ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ❯ Quote the phrase Do you want to proceed? ───────── @@ -213,7 +213,7 @@ struct ScreenHeuristicsTests { @Test func claudeIgnoresStalePermissionPromptOutsideRecentTail() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Do you want to proceed? ❯ 1. Yes @@ -254,7 +254,7 @@ struct ScreenHeuristicsTests { @Test func claudeDetectsBlockedWhenFirstOptionSelectedInLongMenu() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ 需要决策:/release 跳进去发现 APK 没有链时,怎么走接? @@ -282,7 +282,7 @@ struct ScreenHeuristicsTests { @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✻ Crunched for 10s @@ -313,7 +313,7 @@ struct ScreenHeuristicsTests { @Test func claudeViewerChromeAtBottomCarriesNoSignal() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ✻ Tempering… (12s · esc to interrupt) older transcript content @@ -322,7 +322,7 @@ struct ScreenHeuristicsTests { ) == .unknown ) #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ⌕ Search… @@ -336,7 +336,7 @@ struct ScreenHeuristicsTests { // Regression: a chat message quoting "ctrl+r to toggle" used to force // idle while the spinner below showed Claude still working. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ 收尾完成,现状如下: @@ -363,7 +363,7 @@ struct ScreenHeuristicsTests { ] for statusRow in statusRows { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ \(statusRow) ───────── @@ -377,7 +377,7 @@ struct ScreenHeuristicsTests { @Test func claudeQuotedInterruptHintInIdleResponseIsIdle() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The live status row includes the phrase "esc to interrupt". ───────── @@ -391,7 +391,7 @@ struct ScreenHeuristicsTests { @Test func claudeElapsedStatusLineIsScopedAndRequiresACompleteToken() { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ● Forging… (10s · thinking with high effort) ───────── @@ -407,7 +407,7 @@ struct ScreenHeuristicsTests { ] for statusRow in invalidRows { #expect( - claudeLegacyParityState( + claudeProfileState( in: """ \(statusRow) ───────── @@ -419,7 +419,7 @@ struct ScreenHeuristicsTests { } #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ● Retrying… (10s · thinking with high effort) Completed line 1 @@ -438,7 +438,7 @@ struct ScreenHeuristicsTests { // interrupt" above the prompt), but Claude keeps a status line BELOW the // input box. The "/ agents done" segment marks active work. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ Kicked off the scout workflow in the background. ───────── @@ -450,7 +450,7 @@ struct ScreenHeuristicsTests { ) // Idle with an ordinary footer (no workflow line) stays idle. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ Task complete. ───────── @@ -463,7 +463,7 @@ struct ScreenHeuristicsTests { // The marker quoted in conversation (above the prompt) must NOT force // working — the check is anchored to the below-prompt footer. #expect( - claudeLegacyParityState( + claudeProfileState( in: """ ⏺ The run showed 3/5 agents done before it wrapped up. ───────── @@ -475,13 +475,10 @@ struct ScreenHeuristicsTests { ) } - private func claudeLegacyParityState(in screen: String) -> AgentRawState { - let legacy = DetectedAgent.claude.detectState(in: screen) - let profile = ClaudeScreenProfile.detect( - in: AgentScreenSnapshot(canonicalText: agentDetectionRecentText(screen)) - ) - #expect(profile.state == legacy) - return legacy + private func claudeProfileState(in screen: String) -> AgentRawState { + let detection = DetectedAgent.claude.detectScreen(in: screen) + #expect(detection.reason != .legacyDetector) + return detection.state } @Test func codexDetection() { -- 2.51.2 From 47982cd236ca56b0d310451764d05e3ee4e746f5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:32:47 +0900 Subject: [PATCH 3/4] Document Claude screen profile migration --- .../030-agent-status-detection/000-plan.md | 4 + .../012-claude-screen-profile.md | 106 ++++++++++++++++++ docs/components/agent-detection.md | 11 +- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 docs-ai/030-agent-status-detection/012-claude-screen-profile.md diff --git a/docs-ai/030-agent-status-detection/000-plan.md b/docs-ai/030-agent-status-detection/000-plan.md index 7192925e..3996ed9e 100644 --- a/docs-ai/030-agent-status-detection/000-plan.md +++ b/docs-ai/030-agent-status-detection/000-plan.md @@ -138,3 +138,7 @@ the failed attempt to extend the first signal to plain commands is - Updated 2026-08-07: Codex state detection moved to a runtime-owned typed profile with explicit regions, ordered rules, stable reason IDs, and legacy-parity proof — see [011-codex-screen-profile.md](011-codex-screen-profile.md) +- Updated 2026-08-07: Claude state detection moved to its typed profile, current + history-search viewer chrome was fixed from captured evidence, and the third-runtime + adoption gate stopped further migration — see + [012-claude-screen-profile.md](012-claude-screen-profile.md) diff --git a/docs-ai/030-agent-status-detection/012-claude-screen-profile.md b/docs-ai/030-agent-status-detection/012-claude-screen-profile.md new file mode 100644 index 00000000..78810749 --- /dev/null +++ b/docs-ai/030-agent-status-detection/012-claude-screen-profile.md @@ -0,0 +1,106 @@ +# 030.012 — Claude Screen Profile: Action + +| | | +| --- | --- | +| **Status** | Implemented | +| **Date** | 2026-08-07 | +| **Branch** | `feat/claude-screen-profile` | +| **PR** | TBD (stacked on #687) | +| **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 5 | + +## Result + +Claude state detection now runs through one runtime-owned typed Swift profile. The profile +constructs its live regions once from the canonical detector input and evaluates rules in +explicit source order: + +1. transcript/history viewer → `unknown / claude.viewer`; +2. current numbered selection blocker → `blocked / claude.blockedPrompt`; +3. scoped live spinner row → `working / claude.spinner`; +4. complete elapsed-status token → `working / claude.elapsedStatus`; +5. below-prompt workflow footer → `working / claude.backgroundWork`; +6. bordered current composer → `idle / claude.idleComposer`; +7. no match → `idle / fallback.noRuleMatched`. + +Viewer `.unknown` therefore still preserves the last trusted stabilized state; it never +forces idle. Blocked still outranks retained working chrome, spinner/elapsed rows remain +limited to the three recent non-empty lines above the current prompt box, and `agents done` +remains limited to the below-prompt footer. No subagent-wait rule was added because no +captured failing screen supports one. + +`ClaudeScreenRegions` owns Claude viewer, current interaction, live status, below-prompt, +and composer boundaries. Shared mechanical predicates retain one owner while another +runtime still uses them. `AgentScreenSnapshot` now asserts its canonical-tail contract in +Debug before Phase 5 adds the second production profile caller. + +## Capture-backed #676 fix + +The fresh Claude 2.1.223 history-search capture uses current chrome: + +```text +⌕ Filter history… +↑/↓ to nav · Enter to use · Esc to cancel · ctrl+s to scope +``` + +The legacy detector recognized only older `⌕ Search…` / `ctrl+r to toggle` forms and +returned idle. The profile requires the complete current filter/navigation/footer +structure and returns `.unknown / claude.viewer`. The unchanged detector-faithful capture +was promoted from executable quarantine to the normal `unknown` corpus path; its issue +metadata is now explicitly null. This is the migration's only intentional Claude state +difference and is backed by issue #676 plus captured provenance. + +## Migration protocol + +The migration used the same two-commit protocol as Codex: + +- `87451326` added the non-production Claude profile, stable IDs, canonical snapshot guard, + and temporary parity harness. All 22 existing inline Claude call sites stayed equal to + the legacy detector; seven ordinary captures stayed equal, while the quarantined viewer + explicitly proved the one intended `idle → unknown` difference. +- `a8549eb6` routed production to the profile, required every existing inline test to + return a non-legacy reason, removed the temporary comparator and legacy Claude helpers, + and promoted the viewer fixture. + +At no point does a shipped production path run both Claude classifiers. The remaining 13 +runtimes still use their unchanged legacy detectors and report `legacy.detector`. + +## Behavior and ownership + +The current-selection gate, quoted/stale negatives, old viewer forms, spinner glyph set, +elapsed token parser, background-work footer, canonical 24-line tail, exact cache identity, +3-second hold, polling, and display-state projection are unchanged. Positive idle composer +recognition changes only the reason; ordinary no-match remains idle. + +Claude-exclusive parsing moved out of `ScreenHeuristics.swift` and the legacy detector was +removed. Recent-line extraction, numbered-choice parsing, generic confirmation vocabulary, +and the spinner glyph predicate remain shared because Codex or unmigrated runtimes still +call them. + +## Validation + +TDD and parity evidence: + +- profile tests initially failed to compile because the Claude profile API did not exist; +- the non-production profile then passed all 22 inline parity call sites, seven ordinary + fixture comparisons, and the explicit quarantined-viewer difference; +- production routing next failed in 14 test methods while production still emitted + `legacy.detector`, then passed after the switch; +- focused profile, heuristic, corpus, result, and cache tests: 59 passed; +- full app suite: xcsift reported 2,290 passed; xcresult independently verified 2,292 tests + and zero failures; +- `make check` passed; +- `make bench`: 6 passed. At `a8549eb6`, the complete 15-fixture corpus measured + **2.522 ms median**, versus Phase 4's 2.960 ms (**-14.8%**). Region derivation remains a + net win despite Claude's larger rule set. + +The GUI session remained locked at `loginwindow`, so no new live CLI reason observation is +claimed. Current-version behavior is grounded in the detector-faithful Claude 2.1.223 +captures from [009](009-captured-screen-fixture-corpus.md); retry live verification when +unlocked and on the final simulated-integration branch. + +## Adoption gate + +Stop profile migration after Claude and Codex. No third runtime currently has both fresh +capture provenance and demonstrated maintenance pressure, so further migration would be +ritual rather than evidence-driven. Keep the 13 legacy detectors as an accepted steady +state until a real drift report supplies both conditions. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 2031b4d4..f32fbc92 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -59,10 +59,13 @@ because it can differ from the visible viewport when a pane is scrolled; the def `prowl read` behavior is unchanged. `prowl agents --json` may also include `detection_reason`, a stable classifier rule or -fallback identifier for the latest screen scan. Codex currently reports runtime-owned IDs -for directory trust, hook review, sign-in, confirmation, and working-footer matches; an -ordinary Codex miss reports `fallback.noRuleMatched`. It does not include screen text, and -the text-mode command and app UI remain unchanged. +fallback identifier for the latest screen scan. Codex reports runtime-owned IDs for trust, +hook, sign-in, confirmation, and working-footer matches. Claude does the same for viewer, +blocker, spinner, elapsed-status, background-work, and current-composer regions; current +history-search chrome such as `⌕ Filter history…` reports `claude.viewer` and preserves +the last trusted state. An ordinary migrated-profile miss reports +`fallback.noRuleMatched`. Reasons never include screen text, and the text-mode command and +app UI remain unchanged. To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) -- 2.51.2 From 88d321670525e5c069abd86573491f31d25ae587 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 7 Aug 2026 04:33:33 +0900 Subject: [PATCH 4/4] Link Claude profile pull request --- .../007-screen-profile-migration-plan.md | 4 ++-- .../030-agent-status-detection/012-claude-screen-profile.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md b/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md index 8f62d6ea..707faadc 100644 --- a/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md +++ b/docs-ai/030-agent-status-detection/007-screen-profile-migration-plan.md @@ -2,9 +2,9 @@ | | | | --- | --- | -| **Status** | Approved — implementation in progress | +| **Status** | Implemented — stacked PR review in progress | | **Anchor date** | 2026-08-06 | -| **Primary PRs** | #684 (Phase 1), #685 (Phase 2), #686 (Phase 3), #687 (Phase 4); Phase 5 TBD | +| **Primary PRs** | #684 (Phase 1), #685 (Phase 2), #686 (Phase 3), #687 (Phase 4), #688 (Phase 5) | | **Sources** | Issue #676, PRs #674/#683, herdr `fae0b236` (v0.8.0-era) | | **Related** | [000-plan.md](000-plan.md), [004-live-region-evidence.md](004-live-region-evidence.md), [005-confirmation-live-structure.md](005-confirmation-live-structure.md), [006-current-cli-pre-session-blockers.md](006-current-cli-pre-session-blockers.md), `docs/components/agent-detection.md` | diff --git a/docs-ai/030-agent-status-detection/012-claude-screen-profile.md b/docs-ai/030-agent-status-detection/012-claude-screen-profile.md index 78810749..2037f659 100644 --- a/docs-ai/030-agent-status-detection/012-claude-screen-profile.md +++ b/docs-ai/030-agent-status-detection/012-claude-screen-profile.md @@ -5,7 +5,7 @@ | **Status** | Implemented | | **Date** | 2026-08-07 | | **Branch** | `feat/claude-screen-profile` | -| **PR** | TBD (stacked on #687) | +| **PR** | #688 (stacked on #687) | | **Plan** | [007-screen-profile-migration-plan.md](007-screen-profile-migration-plan.md), Phase 5 | ## Result