main.swift (18152B)
1 import Foundation 2 3 enum Agent: String { 4 case codex 5 case claude 6 } 7 8 struct Options { 9 var agent = Agent.codex 10 var timeout: TimeInterval = 120 11 var limit: Int? 12 var dryRun = false 13 var reviewCorrections = true 14 var inputPath: String? 15 } 16 17 struct ClueEntry { 18 let lineIndex: Int 19 let label: String 20 let answer: String 21 } 22 23 struct ClueIssue { 24 let entry: ClueEntry 25 let clue: String 26 let reason: String 27 } 28 29 struct CluemakeError: Error, CustomStringConvertible { 30 let description: String 31 } 32 33 var standardError = FileHandle.standardError 34 35 func print<T>(_ value: T, to file: inout FileHandle) { 36 let data = "\(value)\n".data(using: .utf8)! 37 file.write(data) 38 } 39 40 func printUsage() { 41 print(""" 42 Usage: Cluemake [options] XD_FILE 43 44 Replaces XD clue text written as [No clue] with clues generated by a coding agent. 45 46 Options: 47 --agent codex|claude Agent command to call. Default: codex. 48 --timeout SECONDS Agent timeout. Default: 120. 49 --limit N Generate at most N clues. 50 --dry-run Print the updated XD to stdout instead of writing the file. 51 --skip-review Skip the final agent review-and-correct pass. 52 -h, --help Show this help. 53 """) 54 } 55 56 func parseOptions(_ arguments: [String]) throws -> Options { 57 var options = Options() 58 var index = 1 59 var inputs: [String] = [] 60 61 func requireValue(_ name: String) throws -> String { 62 guard index + 1 < arguments.count else { 63 throw CluemakeError(description: "Missing value for \(name)") 64 } 65 index += 1 66 return arguments[index] 67 } 68 69 while index < arguments.count { 70 let argument = arguments[index] 71 switch argument { 72 case "--agent": 73 let value = try requireValue(argument) 74 guard let agent = Agent(rawValue: value) else { 75 throw CluemakeError(description: "--agent must be codex or claude") 76 } 77 options.agent = agent 78 case "--timeout": 79 let value = try requireValue(argument) 80 guard let timeout = TimeInterval(value), timeout > 0 else { 81 throw CluemakeError(description: "--timeout must be a positive number") 82 } 83 options.timeout = timeout 84 case "--limit": 85 let value = try requireValue(argument) 86 guard let limit = Int(value), limit > 0 else { 87 throw CluemakeError(description: "--limit must be a positive integer") 88 } 89 options.limit = limit 90 case "--dry-run": 91 options.dryRun = true 92 case "--skip-review": 93 options.reviewCorrections = false 94 case "-h", "--help": 95 printUsage() 96 exit(0) 97 default: 98 inputs.append(argument) 99 } 100 index += 1 101 } 102 103 guard inputs.count == 1 else { 104 throw CluemakeError(description: "Expected exactly one XD file") 105 } 106 options.inputPath = inputs[0] 107 return options 108 } 109 110 func noClueEntries(in lines: [String]) -> [ClueEntry] { 111 let noClueMarker = " [No clue] ~ " 112 113 return lines.enumerated().compactMap { lineIndex, line -> ClueEntry? in 114 guard let dotRange = line.firstIndex(of: "."), 115 let first = line.first, 116 first == "A" || first == "D", 117 line[line.index(after: line.startIndex)..<dotRange].allSatisfy({ $0.isNumber }), 118 line[line.index(after: dotRange)...].hasPrefix(noClueMarker) else { 119 return nil 120 } 121 122 let label = String(line[...dotRange]) 123 let answerStart = line.index(line.index(after: dotRange), offsetBy: noClueMarker.count) 124 let answer = String(line[answerStart...]).trimmingCharacters(in: .whitespacesAndNewlines) 125 guard !answer.isEmpty else { 126 return nil 127 } 128 return ClueEntry(lineIndex: lineIndex, label: label, answer: answer) 129 } 130 } 131 132 func prompt(for entries: [ClueEntry]) -> String { 133 let answerLines = entries 134 .map { "\($0.label) \($0.answer)" } 135 .joined(separator: "\n") 136 137 return """ 138 Write fair American-style crossword clues for these answers. 139 140 Requirements: 141 - Return only one line per answer, in the exact format "A1. Clue text". 142 - Use each label exactly once. 143 - Keep each clue concise, generally 2 to 8 words. 144 - Do not include the answer in its clue. 145 - Do not put the answer after the clue label. 146 - Do not add parenthetical hints or extra explanations after the clue. 147 - Avoid duplicate clue angles across the list. 148 - Avoid fill-in-the-blank clues unless the answer is normally clued that way. 149 - Prefer references from 1990 onward; use older references only when the answer strongly calls for them. 150 151 Answers: 152 \(answerLines) 153 """ 154 } 155 156 func correctionPrompt(for issues: [ClueIssue]) -> String { 157 let issueLines = issues 158 .map { "\($0.entry.label) \($0.entry.answer) | Current clue: \($0.clue) | Problem: \($0.reason)" } 159 .joined(separator: "\n") 160 161 return """ 162 Rewrite only these problematic crossword clues. 163 164 Requirements: 165 - Return only one corrected line per answer, in the exact format "A1. Clue text". 166 - Use each label exactly once. 167 - Keep each clue concise, generally 2 to 8 words. 168 - Do not include the answer in its clue. 169 - Do not put the answer after the clue label. 170 - Do not add parenthetical hints or extra explanations after the clue. 171 - Avoid fill-in-the-blank clues unless the answer is normally clued that way. 172 - Prefer references from 1990 onward; use older references only when the answer strongly calls for them. 173 174 Problematic clues: 175 \(issueLines) 176 """ 177 } 178 179 func reviewPrompt(for entries: [ClueEntry], cluesByLabel: [String: String]) -> String { 180 let clueLines = entries 181 .compactMap { entry -> String? in 182 guard let clue = cluesByLabel[entry.label] else { 183 return nil 184 } 185 return "\(entry.label) \(clue) ~ \(entry.answer)" 186 } 187 .joined(separator: "\n") 188 189 return """ 190 Review these crossword clue/answer pairs for likely factual errors, answer-in-clue problems, unfair wording, parenthetical extra hints, or clues that point to a different answer. 191 192 Requirements: 193 - Return only corrected clue lines for entries that need changes, in the exact format "A1. Clue text". 194 - If no corrections are needed, return only "NONE". 195 - Do not explain the corrections. 196 - Do not include the answer in its clue. 197 - Do not add parenthetical hints or extra explanations after the clue. 198 - Keep each replacement clue concise, generally 2 to 8 words. 199 200 Clue/answer pairs: 201 \(clueLines) 202 """ 203 } 204 205 func command(for agent: Agent, prompt: String) -> (String, [String]) { 206 switch agent { 207 case .codex: 208 return ("codex", [ 209 "exec", 210 "--ask-for-approval", "never", 211 "--sandbox", "read-only", 212 prompt 213 ]) 214 case .claude: 215 return ("claude", [ 216 "-p", 217 "--tools", "", 218 "--permission-mode", "dontAsk", 219 prompt 220 ]) 221 } 222 } 223 224 func readAvailableData(from pipe: Pipe) -> String { 225 let data = pipe.fileHandleForReading.readDataToEndOfFile() 226 return String(data: data, encoding: .utf8) ?? "" 227 } 228 229 func runAgent(_ agent: Agent, prompt: String, timeout: TimeInterval) throws -> String { 230 let process = Process() 231 let stdout = Pipe() 232 let stderr = Pipe() 233 let (program, arguments) = command(for: agent, prompt: prompt) 234 235 process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 236 process.arguments = [program] + arguments 237 process.standardOutput = stdout 238 process.standardError = stderr 239 240 try process.run() 241 242 let deadline = Date().addingTimeInterval(timeout) 243 while process.isRunning && Date() < deadline { 244 Thread.sleep(forTimeInterval: 0.1) 245 } 246 247 if process.isRunning { 248 process.terminate() 249 throw CluemakeError(description: "\(agent.rawValue) timed out after \(Int(timeout)) seconds") 250 } 251 252 let output = readAvailableData(from: stdout) 253 let errorOutput = readAvailableData(from: stderr) 254 guard process.terminationStatus == 0 else { 255 let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines) 256 throw CluemakeError(description: "\(agent.rawValue) exited with status \(process.terminationStatus): \(detail)") 257 } 258 259 guard !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 260 let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines) 261 throw CluemakeError(description: "\(agent.rawValue) returned an empty response. \(detail)") 262 } 263 return output 264 } 265 266 func sanitizeClue(_ output: String) -> String { 267 var clue = output.trimmingCharacters(in: .whitespacesAndNewlines) 268 269 if clue.hasPrefix("\""), clue.hasSuffix("\""), clue.count >= 2 { 270 clue.removeFirst() 271 clue.removeLast() 272 } 273 if clue.hasPrefix("'"), clue.hasSuffix("'"), clue.count >= 2 { 274 clue.removeFirst() 275 clue.removeLast() 276 } 277 return clue.trimmingCharacters(in: .whitespacesAndNewlines) 278 } 279 280 func parseClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] { 281 let expectedLabels = Set(expectedEntries.map(\.label)) 282 var clues: [String: String] = [:] 283 284 for rawLine in output.components(separatedBy: .newlines) { 285 var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) 286 if line.hasPrefix("- ") || line.hasPrefix("* ") { 287 line.removeFirst(2) 288 } 289 290 guard let dotIndex = line.firstIndex(of: ".") else { 291 continue 292 } 293 294 let label = String(line[...dotIndex]) 295 guard expectedLabels.contains(label) else { 296 continue 297 } 298 299 let clueStart = line.index(after: dotIndex) 300 let clue = sanitizeClue(String(line[clueStart...])) 301 guard !clue.isEmpty else { 302 throw CluemakeError(description: "\(label) has an empty clue") 303 } 304 guard clues[label] == nil else { 305 throw CluemakeError(description: "\(label) was returned more than once") 306 } 307 clues[label] = clue 308 } 309 310 let missing = expectedEntries.map(\.label).filter { clues[$0] == nil } 311 guard missing.isEmpty else { 312 throw CluemakeError(description: "Missing clue(s) for: \(missing.joined(separator: ", "))") 313 } 314 315 return clues 316 } 317 318 func parseCorrectionClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] { 319 if output.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "NONE" { 320 return [:] 321 } 322 323 let expectedLabels = Set(expectedEntries.map(\.label)) 324 var clues: [String: String] = [:] 325 326 for rawLine in output.components(separatedBy: .newlines) { 327 var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) 328 if line.hasPrefix("- ") || line.hasPrefix("* ") { 329 line.removeFirst(2) 330 } 331 332 guard let dotIndex = line.firstIndex(of: ".") else { 333 continue 334 } 335 336 let label = String(line[...dotIndex]) 337 guard expectedLabels.contains(label) else { 338 continue 339 } 340 341 let clueStart = line.index(after: dotIndex) 342 let clue = sanitizeClue(String(line[clueStart...])) 343 guard !clue.isEmpty else { 344 throw CluemakeError(description: "\(label) has an empty corrected clue") 345 } 346 guard clues[label] == nil else { 347 throw CluemakeError(description: "\(label) correction was returned more than once") 348 } 349 clues[label] = clue 350 } 351 352 return clues 353 } 354 355 func issues(in cluesByLabel: [String: String], entries: [ClueEntry]) -> [ClueIssue] { 356 entries.compactMap { entry in 357 guard let clue = cluesByLabel[entry.label] else { 358 return nil 359 } 360 361 if containsParenthetical(in: clue) { 362 return ClueIssue(entry: entry, clue: clue, reason: "contains a parenthetical hint") 363 } 364 if clueStartsWithAnswer(clue, answer: entry.answer) { 365 return ClueIssue(entry: entry, clue: clue, reason: "starts with the answer") 366 } 367 if clueContainsAnswerAsWord(clue, answer: entry.answer) { 368 return ClueIssue(entry: entry, clue: clue, reason: "contains the answer") 369 } 370 return nil 371 } 372 } 373 374 func containsParenthetical(in clue: String) -> Bool { 375 clue.contains("(") || clue.contains(")") 376 } 377 378 func normalized(_ text: String) -> String { 379 text.uppercased() 380 } 381 382 func clueStartsWithAnswer(_ clue: String, answer: String) -> Bool { 383 let normalizedClue = normalized(clue) 384 let normalizedAnswer = normalized(answer) 385 guard normalizedClue.hasPrefix(normalizedAnswer) else { 386 return false 387 } 388 389 return isAnswerBoundary(in: normalizedClue, before: nil, afterAnswerAt: normalizedAnswer.count) 390 } 391 392 func clueContainsAnswerAsWord(_ clue: String, answer: String) -> Bool { 393 let normalizedClue = clue.uppercased() 394 let normalizedAnswer = answer.uppercased() 395 var searchStart = normalizedClue.startIndex 396 397 while let range = normalizedClue.range(of: normalizedAnswer, range: searchStart..<normalizedClue.endIndex) { 398 let before = range.lowerBound == normalizedClue.startIndex ? nil : normalizedClue.index(before: range.lowerBound) 399 if isAnswerBoundary(in: normalizedClue, before: before, afterAnswerAt: normalizedClue.distance(from: normalizedClue.startIndex, to: range.upperBound)) { 400 return true 401 } 402 searchStart = range.upperBound 403 } 404 405 return false 406 } 407 408 func isAnswerBoundary(in text: String, before: String.Index?, afterAnswerAt offset: Int) -> Bool { 409 if let before, text[before].isLetter { 410 return false 411 } 412 413 guard offset < text.count else { 414 return true 415 } 416 417 let after = text.index(text.startIndex, offsetBy: offset) 418 return !text[after].isLetter 419 } 420 421 func correctedClues( 422 for clueIssues: [ClueIssue], 423 agent: Agent, 424 timeout: TimeInterval 425 ) throws -> [String: String] { 426 guard !clueIssues.isEmpty else { 427 return [:] 428 } 429 430 print("Correcting \(clueIssues.count) clue issue(s) with \(agent.rawValue)", to: &standardError) 431 let entries = clueIssues.map(\.entry) 432 let response = try runAgent(agent, prompt: correctionPrompt(for: clueIssues), timeout: timeout) 433 let corrections = try parseClues(response, expectedEntries: entries) 434 let remainingIssues = issues(in: corrections, entries: entries) 435 guard remainingIssues.isEmpty else { 436 let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ") 437 throw CluemakeError(description: "Corrected clue(s) still have issue(s): \(labels)") 438 } 439 return corrections 440 } 441 442 func reviewedCorrections( 443 entries: [ClueEntry], 444 cluesByLabel: [String: String], 445 agent: Agent, 446 timeout: TimeInterval 447 ) throws -> [String: String] { 448 print("Reviewing clues for corrections with \(agent.rawValue)", to: &standardError) 449 let response = try runAgent(agent, prompt: reviewPrompt(for: entries, cluesByLabel: cluesByLabel), timeout: timeout) 450 let corrections = try parseCorrectionClues(response, expectedEntries: entries) 451 let entriesByLabel = Dictionary(uniqueKeysWithValues: entries.map { ($0.label, $0) }) 452 let correctedEntries = corrections.keys.compactMap { entriesByLabel[$0] } 453 let remainingIssues = issues(in: corrections, entries: correctedEntries) 454 guard remainingIssues.isEmpty else { 455 let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ") 456 throw CluemakeError(description: "Reviewed correction(s) still have issue(s): \(labels)") 457 } 458 return corrections 459 } 460 461 func replaceNoClueLine(_ line: String, with clue: String) -> String { 462 line.replacingOccurrences(of: "[No clue]", with: clue) 463 } 464 465 do { 466 let options = try parseOptions(CommandLine.arguments) 467 let inputPath = options.inputPath! 468 let inputURL = URL(fileURLWithPath: inputPath) 469 var isDirectory: ObjCBool = false 470 471 guard FileManager.default.fileExists(atPath: inputPath, isDirectory: &isDirectory), !isDirectory.boolValue else { 472 throw CluemakeError(description: "XD file not found: \(inputPath)") 473 } 474 475 let original = try String(contentsOf: inputURL, encoding: .utf8) 476 var lines = original.components(separatedBy: .newlines) 477 var entries = noClueEntries(in: lines) 478 if let limit = options.limit { 479 entries = Array(entries.prefix(limit)) 480 } 481 482 guard !entries.isEmpty else { 483 print("No [No clue] entries found.", to: &standardError) 484 exit(0) 485 } 486 487 print("Cluing \(entries.count) answer(s) with \(options.agent.rawValue)", to: &standardError) 488 let response = try runAgent(options.agent, prompt: prompt(for: entries), timeout: options.timeout) 489 var cluesByLabel = try parseClues(response, expectedEntries: entries) 490 let corrections = try correctedClues( 491 for: issues(in: cluesByLabel, entries: entries), 492 agent: options.agent, 493 timeout: options.timeout 494 ) 495 for (label, clue) in corrections { 496 cluesByLabel[label] = clue 497 } 498 if options.reviewCorrections { 499 let reviewCorrections = try reviewedCorrections( 500 entries: entries, 501 cluesByLabel: cluesByLabel, 502 agent: options.agent, 503 timeout: options.timeout 504 ) 505 for (label, clue) in reviewCorrections { 506 cluesByLabel[label] = clue 507 } 508 } 509 510 for entry in entries { 511 let clue = cluesByLabel[entry.label]! 512 lines[entry.lineIndex] = replaceNoClueLine(lines[entry.lineIndex], with: clue) 513 } 514 515 let updated = lines.joined(separator: "\n") 516 if options.dryRun { 517 print(updated, terminator: "") 518 } else { 519 try updated.write(to: inputURL, atomically: true, encoding: .utf8) 520 print("Updated \(entries.count) clue(s) in \(inputPath).", to: &standardError) 521 } 522 } catch { 523 print("Cluemake: \(error)", to: &standardError) 524 exit(1) 525 }