commit 5212b38621c3869450c4bcf9ad7aec097538eb22
parent 99380cca5615528148ccdf247fe98c84efd95ffa
Author: Michael Camilleri <[email protected]>
Date: Tue, 19 May 2026 11:10:37 +0900
Add Cluemake for generated crossword clues
Cluemake is a new Crossmake executable that fills '[No clue]' XD entries by
calling a coding agent, defaulting to Codex and optionally using Claude. It
sends the puzzle's unclued answers in one labelled batch, parses labelled
'<ID>. <Clue>' text responses back onto the XD lines, and keeps writes atomic
at the puzzle level by validating labels before replacing any clue text.
The tool also performs correction passes instead of throwing away an entire
generation: local checks catch parentheticals and answer-in-clue mistakes, then
ask the agent for replacement lines only for those entries; a final optional
review pass asks for correction lines or NONE. select_puzzles.sh now builds
Cluemake alongside Pickmake, clues each copied puzzle, supports --clue-agent,
--clue-timeout, --skip-clues, and retries incomplete clue responses.
Additionally, Fillmake's default XD title is now Crossmate Puzzle, so newly
generated and selected puzzles are ready to bundle without manual retitling.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 596 insertions(+), 7 deletions(-)
diff --git a/Crossmake/Package.swift b/Crossmake/Package.swift
@@ -8,12 +8,14 @@ let package = Package(
.macOS(.v13)
],
products: [
+ .executable(name: "Cluemake", targets: ["Cluemake"]),
.executable(name: "Fillmake", targets: ["Fillmake"]),
.executable(name: "Gridmake", targets: ["Gridmake"]),
.executable(name: "Pickmake", targets: ["Pickmake"]),
.executable(name: "Wordmake", targets: ["Wordmake"])
],
targets: [
+ .executableTarget(name: "Cluemake"),
.executableTarget(
name: "Fillmake",
resources: [
diff --git a/Crossmake/Scripts/select_puzzles.sh b/Crossmake/Scripts/select_puzzles.sh
@@ -4,14 +4,19 @@ set -euo pipefail
COUNT="${1:-10}"
DEFAULT_CANDIDATE_DIR="Generated"
PICKMAKE_EXECUTABLE=".build/release/Pickmake"
+CLUEMAKE_EXECUTABLE=".build/release/Cluemake"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CROSSMAKE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CALLER_DIR="$(pwd)"
OUTPUT_DIR="${CROSSMAKE_DIR}/Picked"
CLEAN=false
+CLUE_AGENT="${CROSSMAKE_CLUE_AGENT:-codex}"
+CLUE_TIMEOUT="${CROSSMAKE_CLUE_TIMEOUT:-300}"
+CLUE_ATTEMPTS="${CROSSMAKE_CLUE_ATTEMPTS:-3}"
+CLUE_PUZZLES=true
if ! [[ "$COUNT" =~ ^[0-9]+$ ]] || ((10#$COUNT < 1)); then
- echo "Usage: $0 [positive-puzzle-count] [-o output-dir] [--clean] [candidate-dir-or-xd-file ...]" >&2
+ echo "Usage: $0 [positive-puzzle-count] [-o output-dir] [--clean] [--clue-agent codex|claude] [--clue-timeout seconds] [--skip-clues] [candidate-dir-or-xd-file ...]" >&2
exit 1
fi
@@ -39,6 +44,26 @@ while (($# > 0)); do
CLEAN=true
shift
;;
+ --clue-agent)
+ if (($# < 2)); then
+ echo "$1 requires codex or claude" >&2
+ exit 1
+ fi
+ CLUE_AGENT="$2"
+ shift 2
+ ;;
+ --clue-timeout)
+ if (($# < 2)); then
+ echo "$1 requires a timeout in seconds" >&2
+ exit 1
+ fi
+ CLUE_TIMEOUT="$2"
+ shift 2
+ ;;
+ --skip-clues)
+ CLUE_PUZZLES=false
+ shift
+ ;;
*)
inputs+=("$(resolve_path "$1")")
shift
@@ -46,20 +71,40 @@ while (($# > 0)); do
esac
done
+if [[ "$CLUE_AGENT" != "codex" && "$CLUE_AGENT" != "claude" ]]; then
+ echo "Clue agent must be codex or claude" >&2
+ exit 1
+fi
+
+if ! [[ "$CLUE_TIMEOUT" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
+ echo "Clue timeout must be a positive number" >&2
+ exit 1
+fi
+
+if ! [[ "$CLUE_ATTEMPTS" =~ ^[0-9]+$ ]] || ((10#$CLUE_ATTEMPTS < 1)); then
+ echo "CROSSMAKE_CLUE_ATTEMPTS must be a positive integer" >&2
+ exit 1
+fi
+
if ((${#inputs[@]} == 0)); then
inputs+=("${CROSSMAKE_DIR}/${DEFAULT_CANDIDATE_DIR}")
fi
cd "$CROSSMAKE_DIR"
-echo "Building Pickmake in release mode" >&2
-swift build -c release --product Pickmake
+echo "Building Pickmake and Cluemake in release mode" >&2
+swift build -c release --product Pickmake --product Cluemake
if [[ ! -x "$PICKMAKE_EXECUTABLE" ]]; then
echo "Pickmake executable not found after build: $PICKMAKE_EXECUTABLE" >&2
exit 1
fi
+if [[ "$CLUE_PUZZLES" == true && ! -x "$CLUEMAKE_EXECUTABLE" ]]; then
+ echo "Cluemake executable not found after build: $CLUEMAKE_EXECUTABLE" >&2
+ exit 1
+fi
+
selected_paths="$("$PICKMAKE_EXECUTABLE" --count "$COUNT" --verbose "${inputs[@]}")"
if [[ -z "$selected_paths" ]]; then
@@ -77,9 +122,26 @@ fi
copied_count=0
while IFS= read -r selected_path; do
[[ -n "$selected_path" ]] || continue
- cp "$selected_path" "$OUTPUT_DIR/"
- echo "Copied ${selected_path} -> ${OUTPUT_DIR}/"
+ output_path="${OUTPUT_DIR}/${selected_path##*/}"
+ cp "$selected_path" "$output_path"
+ echo "Copied ${selected_path} -> ${output_path}"
+ if [[ "$CLUE_PUZZLES" == true ]]; then
+ echo "Generating clues for ${output_path} with ${CLUE_AGENT}" >&2
+ clue_attempt=1
+ until "$CLUEMAKE_EXECUTABLE" --agent "$CLUE_AGENT" --timeout "$CLUE_TIMEOUT" "$output_path"; do
+ if ((clue_attempt >= 10#$CLUE_ATTEMPTS)); then
+ echo "Failed to generate clues for ${output_path} after ${CLUE_ATTEMPTS} attempt(s)." >&2
+ exit 1
+ fi
+ clue_attempt=$((clue_attempt + 1))
+ echo "Retrying clues for ${output_path} (${clue_attempt}/${CLUE_ATTEMPTS})" >&2
+ done
+ fi
copied_count=$((copied_count + 1))
done <<<"$selected_paths"
-echo "Copied ${copied_count} puzzle(s) into ${OUTPUT_DIR}." >&2
+if [[ "$CLUE_PUZZLES" == true ]]; then
+ echo "Copied and clued ${copied_count} puzzle(s) into ${OUTPUT_DIR}." >&2
+else
+ echo "Copied ${copied_count} puzzle(s) into ${OUTPUT_DIR}." >&2
+fi
diff --git a/Crossmake/Sources/Cluemake/main.swift b/Crossmake/Sources/Cluemake/main.swift
@@ -0,0 +1,525 @@
+import Foundation
+
+enum Agent: String {
+ case codex
+ case claude
+}
+
+struct Options {
+ var agent = Agent.codex
+ var timeout: TimeInterval = 120
+ var limit: Int?
+ var dryRun = false
+ var reviewCorrections = true
+ var inputPath: String?
+}
+
+struct ClueEntry {
+ let lineIndex: Int
+ let label: String
+ let answer: String
+}
+
+struct ClueIssue {
+ let entry: ClueEntry
+ let clue: String
+ let reason: String
+}
+
+struct CluemakeError: Error, CustomStringConvertible {
+ let description: String
+}
+
+var standardError = FileHandle.standardError
+
+func print<T>(_ value: T, to file: inout FileHandle) {
+ let data = "\(value)\n".data(using: .utf8)!
+ file.write(data)
+}
+
+func printUsage() {
+ print("""
+ Usage: Cluemake [options] XD_FILE
+
+ Replaces XD clue text written as [No clue] with clues generated by a coding agent.
+
+ Options:
+ --agent codex|claude Agent command to call. Default: codex.
+ --timeout SECONDS Agent timeout. Default: 120.
+ --limit N Generate at most N clues.
+ --dry-run Print the updated XD to stdout instead of writing the file.
+ --skip-review Skip the final agent review-and-correct pass.
+ -h, --help Show this help.
+ """)
+}
+
+func parseOptions(_ arguments: [String]) throws -> Options {
+ var options = Options()
+ var index = 1
+ var inputs: [String] = []
+
+ func requireValue(_ name: String) throws -> String {
+ guard index + 1 < arguments.count else {
+ throw CluemakeError(description: "Missing value for \(name)")
+ }
+ index += 1
+ return arguments[index]
+ }
+
+ while index < arguments.count {
+ let argument = arguments[index]
+ switch argument {
+ case "--agent":
+ let value = try requireValue(argument)
+ guard let agent = Agent(rawValue: value) else {
+ throw CluemakeError(description: "--agent must be codex or claude")
+ }
+ options.agent = agent
+ case "--timeout":
+ let value = try requireValue(argument)
+ guard let timeout = TimeInterval(value), timeout > 0 else {
+ throw CluemakeError(description: "--timeout must be a positive number")
+ }
+ options.timeout = timeout
+ case "--limit":
+ let value = try requireValue(argument)
+ guard let limit = Int(value), limit > 0 else {
+ throw CluemakeError(description: "--limit must be a positive integer")
+ }
+ options.limit = limit
+ case "--dry-run":
+ options.dryRun = true
+ case "--skip-review":
+ options.reviewCorrections = false
+ case "-h", "--help":
+ printUsage()
+ exit(0)
+ default:
+ inputs.append(argument)
+ }
+ index += 1
+ }
+
+ guard inputs.count == 1 else {
+ throw CluemakeError(description: "Expected exactly one XD file")
+ }
+ options.inputPath = inputs[0]
+ return options
+}
+
+func noClueEntries(in lines: [String]) -> [ClueEntry] {
+ let noClueMarker = " [No clue] ~ "
+
+ return lines.enumerated().compactMap { lineIndex, line -> ClueEntry? in
+ guard let dotRange = line.firstIndex(of: "."),
+ let first = line.first,
+ first == "A" || first == "D",
+ line[line.index(after: line.startIndex)..<dotRange].allSatisfy({ $0.isNumber }),
+ line[line.index(after: dotRange)...].hasPrefix(noClueMarker) else {
+ return nil
+ }
+
+ let label = String(line[...dotRange])
+ let answerStart = line.index(line.index(after: dotRange), offsetBy: noClueMarker.count)
+ let answer = String(line[answerStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !answer.isEmpty else {
+ return nil
+ }
+ return ClueEntry(lineIndex: lineIndex, label: label, answer: answer)
+ }
+}
+
+func prompt(for entries: [ClueEntry]) -> String {
+ let answerLines = entries
+ .map { "\($0.label) \($0.answer)" }
+ .joined(separator: "\n")
+
+ return """
+ Write fair American-style crossword clues for these answers.
+
+ Requirements:
+ - Return only one line per answer, in the exact format "A1. Clue text".
+ - Use each label exactly once.
+ - Keep each clue concise, generally 2 to 8 words.
+ - Do not include the answer in its clue.
+ - Do not put the answer after the clue label.
+ - Do not add parenthetical hints or extra explanations after the clue.
+ - Avoid duplicate clue angles across the list.
+ - Avoid fill-in-the-blank clues unless the answer is normally clued that way.
+ - Prefer references from 1990 onward; use older references only when the answer strongly calls for them.
+
+ Answers:
+ \(answerLines)
+ """
+}
+
+func correctionPrompt(for issues: [ClueIssue]) -> String {
+ let issueLines = issues
+ .map { "\($0.entry.label) \($0.entry.answer) | Current clue: \($0.clue) | Problem: \($0.reason)" }
+ .joined(separator: "\n")
+
+ return """
+ Rewrite only these problematic crossword clues.
+
+ Requirements:
+ - Return only one corrected line per answer, in the exact format "A1. Clue text".
+ - Use each label exactly once.
+ - Keep each clue concise, generally 2 to 8 words.
+ - Do not include the answer in its clue.
+ - Do not put the answer after the clue label.
+ - Do not add parenthetical hints or extra explanations after the clue.
+ - Avoid fill-in-the-blank clues unless the answer is normally clued that way.
+ - Prefer references from 1990 onward; use older references only when the answer strongly calls for them.
+
+ Problematic clues:
+ \(issueLines)
+ """
+}
+
+func reviewPrompt(for entries: [ClueEntry], cluesByLabel: [String: String]) -> String {
+ let clueLines = entries
+ .compactMap { entry -> String? in
+ guard let clue = cluesByLabel[entry.label] else {
+ return nil
+ }
+ return "\(entry.label) \(clue) ~ \(entry.answer)"
+ }
+ .joined(separator: "\n")
+
+ return """
+ 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.
+
+ Requirements:
+ - Return only corrected clue lines for entries that need changes, in the exact format "A1. Clue text".
+ - If no corrections are needed, return only "NONE".
+ - Do not explain the corrections.
+ - Do not include the answer in its clue.
+ - Do not add parenthetical hints or extra explanations after the clue.
+ - Keep each replacement clue concise, generally 2 to 8 words.
+
+ Clue/answer pairs:
+ \(clueLines)
+ """
+}
+
+func command(for agent: Agent, prompt: String) -> (String, [String]) {
+ switch agent {
+ case .codex:
+ return ("codex", [
+ "exec",
+ "--ask-for-approval", "never",
+ "--sandbox", "read-only",
+ prompt
+ ])
+ case .claude:
+ return ("claude", [
+ "-p",
+ "--tools", "",
+ "--permission-mode", "dontAsk",
+ prompt
+ ])
+ }
+}
+
+func readAvailableData(from pipe: Pipe) -> String {
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ return String(data: data, encoding: .utf8) ?? ""
+}
+
+func runAgent(_ agent: Agent, prompt: String, timeout: TimeInterval) throws -> String {
+ let process = Process()
+ let stdout = Pipe()
+ let stderr = Pipe()
+ let (program, arguments) = command(for: agent, prompt: prompt)
+
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = [program] + arguments
+ process.standardOutput = stdout
+ process.standardError = stderr
+
+ try process.run()
+
+ let deadline = Date().addingTimeInterval(timeout)
+ while process.isRunning && Date() < deadline {
+ Thread.sleep(forTimeInterval: 0.1)
+ }
+
+ if process.isRunning {
+ process.terminate()
+ throw CluemakeError(description: "\(agent.rawValue) timed out after \(Int(timeout)) seconds")
+ }
+
+ let output = readAvailableData(from: stdout)
+ let errorOutput = readAvailableData(from: stderr)
+ guard process.terminationStatus == 0 else {
+ let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines)
+ throw CluemakeError(description: "\(agent.rawValue) exited with status \(process.terminationStatus): \(detail)")
+ }
+
+ guard !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines)
+ throw CluemakeError(description: "\(agent.rawValue) returned an empty response. \(detail)")
+ }
+ return output
+}
+
+func sanitizeClue(_ output: String) -> String {
+ var clue = output.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if clue.hasPrefix("\""), clue.hasSuffix("\""), clue.count >= 2 {
+ clue.removeFirst()
+ clue.removeLast()
+ }
+ if clue.hasPrefix("'"), clue.hasSuffix("'"), clue.count >= 2 {
+ clue.removeFirst()
+ clue.removeLast()
+ }
+ return clue.trimmingCharacters(in: .whitespacesAndNewlines)
+}
+
+func parseClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] {
+ let expectedLabels = Set(expectedEntries.map(\.label))
+ var clues: [String: String] = [:]
+
+ for rawLine in output.components(separatedBy: .newlines) {
+ var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ if line.hasPrefix("- ") || line.hasPrefix("* ") {
+ line.removeFirst(2)
+ }
+
+ guard let dotIndex = line.firstIndex(of: ".") else {
+ continue
+ }
+
+ let label = String(line[...dotIndex])
+ guard expectedLabels.contains(label) else {
+ continue
+ }
+
+ let clueStart = line.index(after: dotIndex)
+ let clue = sanitizeClue(String(line[clueStart...]))
+ guard !clue.isEmpty else {
+ throw CluemakeError(description: "\(label) has an empty clue")
+ }
+ guard clues[label] == nil else {
+ throw CluemakeError(description: "\(label) was returned more than once")
+ }
+ clues[label] = clue
+ }
+
+ let missing = expectedEntries.map(\.label).filter { clues[$0] == nil }
+ guard missing.isEmpty else {
+ throw CluemakeError(description: "Missing clue(s) for: \(missing.joined(separator: ", "))")
+ }
+
+ return clues
+}
+
+func parseCorrectionClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] {
+ if output.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "NONE" {
+ return [:]
+ }
+
+ let expectedLabels = Set(expectedEntries.map(\.label))
+ var clues: [String: String] = [:]
+
+ for rawLine in output.components(separatedBy: .newlines) {
+ var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ if line.hasPrefix("- ") || line.hasPrefix("* ") {
+ line.removeFirst(2)
+ }
+
+ guard let dotIndex = line.firstIndex(of: ".") else {
+ continue
+ }
+
+ let label = String(line[...dotIndex])
+ guard expectedLabels.contains(label) else {
+ continue
+ }
+
+ let clueStart = line.index(after: dotIndex)
+ let clue = sanitizeClue(String(line[clueStart...]))
+ guard !clue.isEmpty else {
+ throw CluemakeError(description: "\(label) has an empty corrected clue")
+ }
+ guard clues[label] == nil else {
+ throw CluemakeError(description: "\(label) correction was returned more than once")
+ }
+ clues[label] = clue
+ }
+
+ return clues
+}
+
+func issues(in cluesByLabel: [String: String], entries: [ClueEntry]) -> [ClueIssue] {
+ entries.compactMap { entry in
+ guard let clue = cluesByLabel[entry.label] else {
+ return nil
+ }
+
+ if containsParenthetical(in: clue) {
+ return ClueIssue(entry: entry, clue: clue, reason: "contains a parenthetical hint")
+ }
+ if clueStartsWithAnswer(clue, answer: entry.answer) {
+ return ClueIssue(entry: entry, clue: clue, reason: "starts with the answer")
+ }
+ if clueContainsAnswerAsWord(clue, answer: entry.answer) {
+ return ClueIssue(entry: entry, clue: clue, reason: "contains the answer")
+ }
+ return nil
+ }
+}
+
+func containsParenthetical(in clue: String) -> Bool {
+ clue.contains("(") || clue.contains(")")
+}
+
+func normalized(_ text: String) -> String {
+ text.uppercased()
+}
+
+func clueStartsWithAnswer(_ clue: String, answer: String) -> Bool {
+ let normalizedClue = normalized(clue)
+ let normalizedAnswer = normalized(answer)
+ guard normalizedClue.hasPrefix(normalizedAnswer) else {
+ return false
+ }
+
+ return isAnswerBoundary(in: normalizedClue, before: nil, afterAnswerAt: normalizedAnswer.count)
+}
+
+func clueContainsAnswerAsWord(_ clue: String, answer: String) -> Bool {
+ let normalizedClue = clue.uppercased()
+ let normalizedAnswer = answer.uppercased()
+ var searchStart = normalizedClue.startIndex
+
+ while let range = normalizedClue.range(of: normalizedAnswer, range: searchStart..<normalizedClue.endIndex) {
+ let before = range.lowerBound == normalizedClue.startIndex ? nil : normalizedClue.index(before: range.lowerBound)
+ if isAnswerBoundary(in: normalizedClue, before: before, afterAnswerAt: normalizedClue.distance(from: normalizedClue.startIndex, to: range.upperBound)) {
+ return true
+ }
+ searchStart = range.upperBound
+ }
+
+ return false
+}
+
+func isAnswerBoundary(in text: String, before: String.Index?, afterAnswerAt offset: Int) -> Bool {
+ if let before, text[before].isLetter {
+ return false
+ }
+
+ guard offset < text.count else {
+ return true
+ }
+
+ let after = text.index(text.startIndex, offsetBy: offset)
+ return !text[after].isLetter
+}
+
+func correctedClues(
+ for clueIssues: [ClueIssue],
+ agent: Agent,
+ timeout: TimeInterval
+) throws -> [String: String] {
+ guard !clueIssues.isEmpty else {
+ return [:]
+ }
+
+ print("Correcting \(clueIssues.count) clue issue(s) with \(agent.rawValue)", to: &standardError)
+ let entries = clueIssues.map(\.entry)
+ let response = try runAgent(agent, prompt: correctionPrompt(for: clueIssues), timeout: timeout)
+ let corrections = try parseClues(response, expectedEntries: entries)
+ let remainingIssues = issues(in: corrections, entries: entries)
+ guard remainingIssues.isEmpty else {
+ let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ")
+ throw CluemakeError(description: "Corrected clue(s) still have issue(s): \(labels)")
+ }
+ return corrections
+}
+
+func reviewedCorrections(
+ entries: [ClueEntry],
+ cluesByLabel: [String: String],
+ agent: Agent,
+ timeout: TimeInterval
+) throws -> [String: String] {
+ print("Reviewing clues for corrections with \(agent.rawValue)", to: &standardError)
+ let response = try runAgent(agent, prompt: reviewPrompt(for: entries, cluesByLabel: cluesByLabel), timeout: timeout)
+ let corrections = try parseCorrectionClues(response, expectedEntries: entries)
+ let entriesByLabel = Dictionary(uniqueKeysWithValues: entries.map { ($0.label, $0) })
+ let correctedEntries = corrections.keys.compactMap { entriesByLabel[$0] }
+ let remainingIssues = issues(in: corrections, entries: correctedEntries)
+ guard remainingIssues.isEmpty else {
+ let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ")
+ throw CluemakeError(description: "Reviewed correction(s) still have issue(s): \(labels)")
+ }
+ return corrections
+}
+
+func replaceNoClueLine(_ line: String, with clue: String) -> String {
+ line.replacingOccurrences(of: "[No clue]", with: clue)
+}
+
+do {
+ let options = try parseOptions(CommandLine.arguments)
+ let inputPath = options.inputPath!
+ let inputURL = URL(fileURLWithPath: inputPath)
+ var isDirectory: ObjCBool = false
+
+ guard FileManager.default.fileExists(atPath: inputPath, isDirectory: &isDirectory), !isDirectory.boolValue else {
+ throw CluemakeError(description: "XD file not found: \(inputPath)")
+ }
+
+ let original = try String(contentsOf: inputURL, encoding: .utf8)
+ var lines = original.components(separatedBy: .newlines)
+ var entries = noClueEntries(in: lines)
+ if let limit = options.limit {
+ entries = Array(entries.prefix(limit))
+ }
+
+ guard !entries.isEmpty else {
+ print("No [No clue] entries found.", to: &standardError)
+ exit(0)
+ }
+
+ print("Cluing \(entries.count) answer(s) with \(options.agent.rawValue)", to: &standardError)
+ let response = try runAgent(options.agent, prompt: prompt(for: entries), timeout: options.timeout)
+ var cluesByLabel = try parseClues(response, expectedEntries: entries)
+ let corrections = try correctedClues(
+ for: issues(in: cluesByLabel, entries: entries),
+ agent: options.agent,
+ timeout: options.timeout
+ )
+ for (label, clue) in corrections {
+ cluesByLabel[label] = clue
+ }
+ if options.reviewCorrections {
+ let reviewCorrections = try reviewedCorrections(
+ entries: entries,
+ cluesByLabel: cluesByLabel,
+ agent: options.agent,
+ timeout: options.timeout
+ )
+ for (label, clue) in reviewCorrections {
+ cluesByLabel[label] = clue
+ }
+ }
+
+ for entry in entries {
+ let clue = cluesByLabel[entry.label]!
+ lines[entry.lineIndex] = replaceNoClueLine(lines[entry.lineIndex], with: clue)
+ }
+
+ let updated = lines.joined(separator: "\n")
+ if options.dryRun {
+ print(updated, terminator: "")
+ } else {
+ try updated.write(to: inputURL, atomically: true, encoding: .utf8)
+ print("Updated \(entries.count) clue(s) in \(inputPath).", to: &standardError)
+ }
+} catch {
+ print("Cluemake: \(error)", to: &standardError)
+ exit(1)
+}
diff --git a/Crossmake/Sources/Fillmake/main.swift b/Crossmake/Sources/Fillmake/main.swift
@@ -21,7 +21,7 @@ struct Options {
var seed: UInt64 = UInt64(Date().timeIntervalSince1970)
var timeout: TimeInterval = 30
var breadth = 15
- var title = "Fillmake Puzzle"
+ var title = "Crossmate Puzzle"
var author = "Fillmake"
var optimizeFill = false
var fillReport = false