Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENT_ROLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ Default role-to-model assignment:

Do not assign `Spark` as the only model for production Swift implementation, target dependency changes, DI assembly, repository/service contract changes, Firebase or SDK placement, Widget data-flow changes, StorePattern responsibility changes, commits, pushes, PR creation, or final integration.

### Model dispatch requirements

- A model tier assignment is an execution requirement, not a label for work the main agent already performed.
- When a role is assigned to `Spark` or `Fast`, and tooling can select that model, the main agent must dispatch that role through a separate model or sub-agent call before using its result.
- Do not satisfy a `Spark` or `Fast` role by completing the role directly in `Primary` and describing it as delegated work.
- If the assigned model cannot be called because the model or model-selecting tool is unavailable, apply the fallback policy and report which role was not dispatched to its default tier.
- If the assigned model is available but current tool policy requires explicit user permission before dispatch, missing permission is not fallback. Stop and ask for permission before continuing the required role.
- `Primary` must integrate and verify delegated output, but must not skip the delegated role when the workflow requires it and the assigned model is available.

### Fallback policy

- If `gpt-5.3-codex-spark` is unavailable, assign `Spark` roles to the fastest available read-only capable coding model.
Expand Down Expand Up @@ -123,6 +132,7 @@ Rules:
- Stay inside the role permissions.
- Do not edit files if this is a read-only role.
- Do not run, launch, install, boot, or open the app or Simulator.
- Perform this role in the assigned model context. Do not return work copied from a different model context as this role's own result.
- Stop and report if the task packet conflicts with `AGENTS.md`.
- Return only the output format defined for `<Role Name>`.
```
Expand Down
18 changes: 12 additions & 6 deletions AGENT_WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ The main agent must run every workflow with this protocol.
3. Create the task packet.
4. Assign only the roles required by the selected workflow.
5. Assign each role a model tier from `AGENT_ROLES.md`.
6. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits.
7. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint.
8. Integrate role outputs.
9. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing.
10. Run completion gates.
11. Report changed files, architecture decision, verification result, and unresolved decisions.
6. Dispatch each `Spark` or `Fast` role through a separate model or sub-agent call when tooling can select that assigned model.
7. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits.
8. Do not complete a required `Spark` or `Fast` role directly in `Primary` unless the model or model-selecting tool is unavailable and the fallback policy in `AGENT_ROLES.md` applies.
9. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint.
10. Integrate role outputs.
11. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing.
12. Run completion gates.
13. Report changed files, architecture decision, verification result, delegated roles, model tiers used, and unresolved decisions.

Do not skip the task packet. The task packet is the contract between models.

Expand All @@ -33,6 +35,7 @@ Stop and ask the user before editing when:
- The task packet conflicts with `AGENTS.md`.
- The requested fix requires relaxing a layer boundary.
- A role needs to run, launch, install, boot, or open the app or Simulator.
- A required `Spark` or `Fast` role needs a separate model or sub-agent call, but current tool policy requires user permission that has not been granted.
- The current issue or PR scope is unclear after live GitHub inspection.
- Two editing roles would touch the same file.
- A read-only role reports `Block` or `Needs Owner Decision`.
Expand Down Expand Up @@ -236,6 +239,9 @@ Use for PR body, issue text, release note, README wording, review reply draft, o
### Execution

- Documentation Writer must inspect the actual diff before writing PR or release text.
- When the Documentation Writer role is required and `Spark` is available, the main agent must dispatch the draft to `gpt-5.3-codex-spark` or the configured `Spark` fallback before writing the final response.
- If dispatch requires explicit user permission and it has not been granted, ask before drafting, returning, or posting the Documentation Writer output.
- `Primary` must review the Documentation Writer output against the template, issue scope, and diff before returning or posting it.
- Do not write AI workflow documents under `docs/`.
- If the user asks only for text, return text directly and do not create files.
- If documentation files are changed, keep the change scoped to the requested document.
Expand Down
34 changes: 34 additions & 0 deletions Application/App/Sources/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,40 @@
}
}
},
"account_alert_github_email_conflict_message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "A GitHub email change caused a conflict during the linking process. To protect existing data, linking cannot be completed."
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "GitHub 이메일 변경으로 연결 과정에서 충돌이 발생했습니다. 기존 데이터를 보호하기 위해 연결을 진행할 수 없습니다."
}
}
}
},
"account_alert_github_email_conflict_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unable to Link Account"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "계정을 연결할 수 없음"
}
}
}
},
"account_connect" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
29 changes: 9 additions & 20 deletions Application/Data/Sources/Common/DataLayerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,25 @@
import Foundation
import Core

public enum EmailFetchError: Error, Equatable {
case emailNotFound
case emailMismatch

public var code: String {
switch self {
case .emailMismatch:
"email_mismatch"
case .emailNotFound:
"email_not_found"
}
}
public enum EmailError: Error, Equatable {
case notFound
case mismatch
case githubEmailConflict
}
Comment thread
opficdev marked this conversation as resolved.

public enum DataError: Error {
public enum DataLayerError: Error {
case notAuthenticated
case linkCredentialAlreadyInUse
case invalidData(context: String)

private static let logger = Logger(category: "DataError")
private static let logger = Logger(category: "DataLayerError")

public static func invalidData(
_ context: String,
file: String = #file,
function: String = #function,
line: Int = #line
) -> DataError {
) -> DataLayerError {
logger.error(
"Invalid data: \(context)",
file: file,
Expand All @@ -42,8 +36,3 @@ public enum DataError: Error {
return .invalidData(context: context)
}
}

public enum DataLayerError: Error {
case notAuthenticated
case linkCredentialAlreadyInUse
}
2 changes: 2 additions & 0 deletions Application/Data/Sources/Mapper/ErrorMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ extension Error {
return AuthError.notAuthenticated
case .linkCredentialAlreadyInUse:
return AuthError.linkCredentialAlreadyInUse
case .invalidData(let context):
return DomainLayerError.invalidData(context: context)
case .none:
return self
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public extension PushNotificationResponse {
case .decoded(let category):
todoCategory = category
case .raw(let category):
throw DataError.invalidData(
throw DataLayerError.invalidData(
"PushNotificationResponse.todoCategory must be resolved before toDomain(): \(category)"
)
}
Expand Down
2 changes: 1 addition & 1 deletion Application/Data/Sources/Mapper/TodoMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public extension TodoResponse {
case .decoded(let category):
todoCategory = category
case .raw(let category):
throw DataError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)")
throw DataLayerError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)")
}

return Todo(
Expand Down
4 changes: 2 additions & 2 deletions Application/Data/Sources/Mapper/WebPageMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import Domain
public extension WebPageResponse {
func toDomain() throws -> WebPage {
guard let url = URL(string: url) else {
throw DataError.invalidData("WebPageResponse.url is invalid: \(url)")
throw DataLayerError.invalidData("WebPageResponse.url is invalid: \(url)")
}
guard let displayURL = URL(string: displayURL) else {
throw DataError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)")
throw DataLayerError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)")
}
let imageURL: URL?
if !self.imageURL.isEmpty {
Expand Down
10 changes: 6 additions & 4 deletions Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ final class AuthDataRepositoryImpl: AuthDataRepository {

private extension AuthDataRepositoryImpl {
func mapLinkError(_ error: Error) -> Error {
if let emailFetchError = error as? EmailFetchError {
switch emailFetchError {
case .emailNotFound:
if let emailError = error as? EmailError {
switch emailError {
case .notFound:
return AuthError.linkEmailNotFound
case .emailMismatch:
case .mismatch:
return AuthError.linkEmailMismatch
case .githubEmailConflict:
return AuthError.githubEmailConflict
}
}
Comment thread
opficdev marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository {

private extension AuthenticationRepositoryImpl {
func mapSignInError(_ error: Error) -> Error {
if let emailFetchError = error as? EmailFetchError,
emailFetchError == .emailNotFound {
if let emailError = error as? EmailError,
emailError == .notFound {
return AuthError.emailNotFound
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ private extension PushNotificationRepositoryImpl {
}) {
todoCategory = .user(userTodoCategory)
} else {
throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)")
throw DataLayerError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)")
}

return PushNotificationResponse(
Expand Down
6 changes: 3 additions & 3 deletions Application/Data/Sources/Repository/TodoRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ final class TodoRepositoryImpl: TodoRepository {
return try responses.reduce(into: [Int: TodoReference]()) { partialResult, pair in
let response = try resolve(pair.value, userTodoCategories: userTodoCategories)
guard case let .decoded(category) = response.category else {
throw DataError.invalidData("TodoReferenceResponse.category must be resolved before use")
throw DataLayerError.invalidData("TodoReferenceResponse.category must be resolved before use")
}

partialResult[pair.key] = TodoReference(
Expand Down Expand Up @@ -208,7 +208,7 @@ private extension TodoRepositoryImpl {
}) {
category = .user(userTodoCategory)
} else {
throw DataError.invalidData("TodoResponse.category is invalid: \(id)")
throw DataLayerError.invalidData("TodoResponse.category is invalid: \(id)")
}

return TodoResponse(
Expand Down Expand Up @@ -249,7 +249,7 @@ private extension TodoRepositoryImpl {
}) {
category = .user(userTodoCategory)
} else {
throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)")
throw DataLayerError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)")
}

return TodoReferenceResponse(
Expand Down
1 change: 1 addition & 0 deletions Application/Domain/Sources/Entity/AuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public enum AuthError: Error {
case linkEmailNotFound
case linkEmailMismatch
case linkCredentialAlreadyInUse
case githubEmailConflict
case unsupportedProvider
}
10 changes: 10 additions & 0 deletions Application/Domain/Sources/Entity/DomainLayerError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// DomainLayerError.swift
// Domain
//
// Created by opfic on 7/10/26.
//

public enum DomainLayerError: Error {
case invalidData(context: String)
}
28 changes: 17 additions & 11 deletions Application/Infra/Sources/Service/FunctionAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ private struct FunctionAPIErrorBody: Decodable {
let message: String?
}

private enum FunctionAPIErrorCode: String {
case emailNotFound = "email-not-found"
case emailMismatch = "email-mismatch"
case githubEmailConflict = "github-email-changed-account-conflict"
}
Comment thread
opficdev marked this conversation as resolved.

private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder {
func decodeServerError(
data: Data,
Expand All @@ -121,15 +127,15 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder {
guard let body = try? decoder.decode(
FunctionAPIErrorBody.self,
from: data
) else { return nil }

switch body.code {
case EmailFetchError.emailNotFound.code:
return EmailFetchError.emailNotFound
case EmailFetchError.emailMismatch.code:
return EmailFetchError.emailMismatch
default:
return nil
), let code = FunctionAPIErrorCode(rawValue: body.code) else { return nil }

switch code {
case .emailNotFound:
return EmailError.notFound
case .emailMismatch:
return EmailError.mismatch
case .githubEmailConflict:
return EmailError.githubEmailConflict
}
Comment thread
opficdev marked this conversation as resolved.
}
}
Expand All @@ -145,14 +151,14 @@ private actor FirebaseAuthTokenProvider: NXAuthTokenProvider {
}

extension Error {
var apiEmailFetchError: EmailFetchError? {
var apiEmailError: EmailError? {
guard let error = self as? NXError,
case let .server(
statusCode: _,
data: _,
underlying: underlying
) = error else { return nil }

return underlying as? EmailFetchError
return underlying as? EmailError
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ extension FunctionAPIEndpoint where Response == FunctionAPIResponse {
static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token")
static let refreshAppleAccessToken = Self(method: .post, path: "/auth/apple/access-token")
static let requestAppleRefreshToken = Self(method: .post, path: "/auth/apple/refresh-token")
static let linkGithubProvider = Self(method: .post, path: "/auth/github/link")
static let requestGithubTokens = Self(method: .post, path: "/auth/github/tokens")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,12 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {

guard let appleEmail = credential.email else {
try await revokeAppleAccessToken(token: refreshToken)
throw EmailFetchError.emailNotFound
throw EmailError.notFound
}

if appleEmail != email {
try await revokeAppleAccessToken(token: refreshToken)
throw EmailFetchError.emailMismatch
throw EmailError.mismatch
}

let appleCredential = OAuthProvider.credential(
Expand Down
Loading
Loading