275 lines
12 KiB
Swift
275 lines
12 KiB
Swift
import Foundation
|
|
import NucleicCore
|
|
|
|
/// M0 spike (PLAN milestone 1): spawn `claude` in stream-json, normalize into
|
|
/// `AgentEvent`s, print to console, write the canonical JSONL transcript, and
|
|
/// optionally capture the raw native stream side-by-side as a fixture
|
|
/// (OBSERVABILITY A.5 — capture format == fixture format).
|
|
@main
|
|
struct Spike {
|
|
struct Options {
|
|
var prompt: String?
|
|
var cwd = FileManager.default.currentDirectoryPath
|
|
var model: String?
|
|
var resume: String?
|
|
var executable = "claude"
|
|
var includePartialMessages = false
|
|
var capture = false
|
|
var keepStdin = false
|
|
var approvalMode: ApprovalMode = .interactive
|
|
var allowedTools = ["Read", "Glob", "Grep"]
|
|
var outputDirectory: String?
|
|
}
|
|
|
|
enum ApprovalMode: String {
|
|
case interactive
|
|
case autoAllow = "auto-allow"
|
|
case autoDeny = "auto-deny"
|
|
}
|
|
|
|
static func main() async {
|
|
let options: Options
|
|
do {
|
|
options = try parse(Array(CommandLine.arguments.dropFirst()))
|
|
} catch let error as SpikeError {
|
|
FileHandle.standardError.write(Data((error.message + "\n\n" + usage).utf8))
|
|
exit(64)
|
|
} catch {
|
|
FileHandle.standardError.write(Data(usage.utf8))
|
|
exit(64)
|
|
}
|
|
|
|
guard options.prompt != nil || options.resume != nil else {
|
|
FileHandle.standardError.write(
|
|
Data(("--prompt (or --resume) is required\n\n" + usage).utf8))
|
|
exit(64)
|
|
}
|
|
|
|
let sessionID = SessionID.generate()
|
|
let outputBase = options.outputDirectory.map { URL(fileURLWithPath: $0) }
|
|
?? URL(fileURLWithPath: options.cwd)
|
|
.appendingPathComponent(".nucleic-spike/sessions/\(sessionID.rawValue)")
|
|
|
|
let configuration = ClaudeCodeBackend.Configuration(
|
|
executable: options.executable,
|
|
includePartialMessages: options.includePartialMessages,
|
|
allowedTools: options.allowedTools,
|
|
closeStdinAfterPrompt: !options.keepStdin,
|
|
captureDirectory: options.capture ? outputBase.appendingPathComponent("capture") : nil)
|
|
let backend = ClaudeCodeBackend(configuration: configuration)
|
|
|
|
let header = SessionHeader(
|
|
sessionID: sessionID,
|
|
backend: .claudeCode,
|
|
worktree: options.cwd,
|
|
model: options.model,
|
|
createdAt: Date())
|
|
let transcriptURL = outputBase.appendingPathComponent("transcript.jsonl")
|
|
let writer: TranscriptWriter
|
|
do {
|
|
writer = try TranscriptWriter(url: transcriptURL, header: header)
|
|
} catch {
|
|
fail("Cannot create transcript at \(transcriptURL.path): \(error)")
|
|
}
|
|
|
|
print("session \(sessionID.short) cwd \(options.cwd)")
|
|
print("transcript \(transcriptURL.path)")
|
|
if options.capture {
|
|
print("capture \(outputBase.appendingPathComponent("capture").path)")
|
|
}
|
|
print(String(repeating: "─", count: 72))
|
|
|
|
let stream: AsyncThrowingStream<AgentEvent, Error>
|
|
if let resume = options.resume {
|
|
stream = backend.resume(
|
|
ResumeSpec(
|
|
sessionID: sessionID, backendSessionID: resume, worktree: options.cwd,
|
|
prompt: options.prompt.map { AgentInput(text: $0) }))
|
|
} else {
|
|
stream = backend.start(
|
|
RunSpec(
|
|
sessionID: sessionID,
|
|
worktree: options.cwd,
|
|
prompt: AgentInput(text: options.prompt ?? ""),
|
|
model: options.model))
|
|
}
|
|
|
|
var normalized: [AgentEvent] = []
|
|
var outcome: RunFinished.Outcome = .errored
|
|
do {
|
|
for try await event in stream {
|
|
let canonical = try await writer.append(event)
|
|
normalized.append(canonical)
|
|
render(canonical)
|
|
|
|
if case .approvalRequested(let request) = event.kind {
|
|
let decision = decide(request, mode: options.approvalMode)
|
|
try await backend.respond(to: request.id, decision, by: "spike")
|
|
}
|
|
if case .runFinished(let finished) = event.kind {
|
|
outcome = finished.outcome
|
|
}
|
|
}
|
|
} catch {
|
|
fail("stream error: \(error)")
|
|
}
|
|
|
|
try? await writer.sync()
|
|
try? await writer.close()
|
|
|
|
if options.capture {
|
|
let normalizedURL = outputBase.appendingPathComponent("capture/normalized.json")
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
if let data = try? encoder.encode(normalized) {
|
|
try? data.write(to: normalizedURL)
|
|
}
|
|
}
|
|
|
|
print(String(repeating: "─", count: 72))
|
|
print("outcome: \(outcome.rawValue) events: \(normalized.count)")
|
|
exit(outcome == .completed ? 0 : 2)
|
|
}
|
|
|
|
// MARK: - Approval handling
|
|
|
|
static func decide(_ request: ApprovalRequest, mode: ApprovalMode) -> Decision {
|
|
switch mode {
|
|
case .autoAllow:
|
|
print(" ⚖️ auto-allow: \(request.title)")
|
|
return .allow()
|
|
case .autoDeny:
|
|
print(" ⚖️ auto-deny: \(request.title)")
|
|
return .deny(reason: "Denied by spike policy")
|
|
case .interactive:
|
|
print(" ⚖️ APPROVAL [\(request.risk.rawValue)] \(request.title)")
|
|
print(" input: \(request.input.canonicalString().prefix(200))")
|
|
while true {
|
|
print(" [a]llow / [d]eny / [A]lways this tool / [c]ancel run > ", terminator: "")
|
|
switch readLine(strippingNewline: true) {
|
|
case "a": return .allow()
|
|
case "d": return .deny(reason: "Denied by user")
|
|
case "A": return .allowAlways(.toolName)
|
|
case "c": return .cancelRun
|
|
case nil: return .deny(reason: "No TTY to answer approval")
|
|
default: continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Rendering
|
|
|
|
static func render(_ event: AgentEvent) {
|
|
let prefix = String(format: "%4d", event.seq)
|
|
switch event.kind {
|
|
case .sessionStarted(let started):
|
|
print("\(prefix) ▶ session started backend-id=\(started.backendSessionID) model=\(started.model)")
|
|
case .userText(let chunk):
|
|
print("\(prefix) 🙋 \(chunk.text)")
|
|
case .assistantText(let chunk):
|
|
print("\(prefix) \(chunk.isPartial ? "┆" : "💬") \(chunk.text)")
|
|
case .thinking(let chunk):
|
|
print("\(prefix) 🧠 \(chunk.isPartial ? "┆ " : "")\(chunk.text)")
|
|
case .toolCallStarted(let call):
|
|
print("\(prefix) 🔧 \(call.name) started id=\(call.toolCallID)")
|
|
case .toolCallInputDelta:
|
|
break // too noisy for the console; lands in the transcript regardless
|
|
case .toolCallCompleted(let call):
|
|
print("\(prefix) 🔧 \(call.name) \(call.input.canonicalString().prefix(120))")
|
|
case .toolResult(let result):
|
|
let summary = result.content.canonicalString().prefix(120)
|
|
print("\(prefix) \(result.isError ? "❌" : "✅") result \(summary)")
|
|
case .fileChange(let change):
|
|
print("\(prefix) 📄 \(change.kind.rawValue) \(change.path) (inferred)")
|
|
case .approvalRequested(let request):
|
|
print("\(prefix) ⏸ approval requested: \(request.title)")
|
|
case .approvalResolved(let resolved):
|
|
print("\(prefix) ▶ approval resolved by \(resolved.decidedBy)")
|
|
case .usage(let usage):
|
|
let cost = usage.costUSD.map { String(format: "$%.4f", $0) } ?? "n/a"
|
|
print("\(prefix) 📊 in=\(usage.inputTokens ?? 0) cacheRead=\(usage.cachedInputTokens ?? 0) cacheWrite=\(usage.cacheCreationInputTokens ?? 0) out=\(usage.outputTokens ?? 0) cost=\(cost)")
|
|
case .rateLimit(let rl):
|
|
let resets = rl.resetsAt.map { " resets=\($0)" } ?? ""
|
|
print("\(prefix) ⏳ rate-limit \(rl.rateLimitType ?? "?") status=\(rl.status ?? "?")\(resets)")
|
|
case .turnCompleted:
|
|
print("\(prefix) ── turn completed")
|
|
case .runFinished(let finished):
|
|
print("\(prefix) ■ run finished: \(finished.outcome.rawValue) (\(finished.durationMs ?? 0)ms)")
|
|
case .error(let error):
|
|
print("\(prefix) ⚠️ \(error.recoverable ? "recoverable" : "fatal"): \(error.message)")
|
|
case .note(let note):
|
|
print("\(prefix) 📝 \(note.text)")
|
|
case .raw(let raw):
|
|
print("\(prefix) ❓ raw[\(event.nativeType ?? "?")] \(raw.native.canonicalString().prefix(120))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Arguments
|
|
|
|
struct SpikeError: Error {
|
|
let message: String
|
|
}
|
|
|
|
static func parse(_ arguments: [String]) throws -> Options {
|
|
var options = Options()
|
|
var iterator = arguments.makeIterator()
|
|
func value(for flag: String) throws -> String {
|
|
guard let v = iterator.next() else { throw SpikeError(message: "missing value for \(flag)") }
|
|
return v
|
|
}
|
|
while let argument = iterator.next() {
|
|
switch argument {
|
|
case "--prompt", "-p": options.prompt = try value(for: argument)
|
|
case "--cwd", "-C": options.cwd = try value(for: argument)
|
|
case "--model", "-m": options.model = try value(for: argument)
|
|
case "--resume": options.resume = try value(for: argument)
|
|
case "--claude": options.executable = try value(for: argument)
|
|
case "--partial": options.includePartialMessages = true
|
|
case "--capture": options.capture = true
|
|
case "--keep-stdin": options.keepStdin = true
|
|
case "--out": options.outputDirectory = try value(for: argument)
|
|
case "--approvals":
|
|
let raw = try value(for: argument)
|
|
guard let mode = ApprovalMode(rawValue: raw) else {
|
|
throw SpikeError(message: "unknown approval mode \(raw)")
|
|
}
|
|
options.approvalMode = mode
|
|
case "--allowed-tools":
|
|
options.allowedTools = try value(for: argument)
|
|
.split(separator: ",").map(String.init)
|
|
case "--help", "-h":
|
|
print(usage)
|
|
exit(0)
|
|
default:
|
|
throw SpikeError(message: "unknown argument \(argument)")
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
static let usage = """
|
|
nucleic-spike — M0: run one Claude Code session through the normalized event pipeline
|
|
|
|
USAGE: nucleic-spike --prompt "..." [options]
|
|
|
|
OPTIONS:
|
|
--prompt, -p <text> Initial prompt (required unless --resume)
|
|
--cwd, -C <path> Working directory for the agent (default: .)
|
|
--model, -m <id> Model override
|
|
--resume <session-id> Native resume of a previous backend session
|
|
--partial Enable --include-partial-messages (token deltas)
|
|
--capture Record native.ndjson + normalized.json fixtures
|
|
--approvals <mode> interactive | auto-allow | auto-deny (default: interactive)
|
|
--allowed-tools <list> Comma-separated pre-allowed tools (default: Read,Glob,Grep)
|
|
--out <dir> Output directory (default: ./.nucleic-spike/sessions/<id>)
|
|
--claude <path> Claude executable override (default: claude on PATH)
|
|
"""
|
|
|
|
static func fail(_ message: String) -> Never {
|
|
FileHandle.standardError.write(Data((message + "\n").utf8))
|
|
exit(2)
|
|
}
|
|
}
|