docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1

The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:04:32 +02:00
parent 0bcc7ba3a3
commit db9ad53c1c
38 changed files with 2397 additions and 40 deletions

View file

@ -0,0 +1,162 @@
using System.Text.Json;
namespace AcDream.Runtime.Session;
/// <summary>
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
/// status-event file the launcher tails
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
///
/// <para>
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
/// is a single shared-stdout JSONL diagnostics stream with no per-session
/// file; this class writes one file per session, meant to be read by an
/// external process (the launcher) rather than scraped from console output.
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
/// breaking an existing reader.
/// </para>
///
/// <para>
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
/// so an external tailer can read the file concurrently, writes exactly one
/// line, flushes, and closes — there is no long-lived file handle to leak or
/// to dispose. A writer constructed with a <see langword="null"/> or blank
/// path is a permanent no-op: every method becomes a cheap null-check, so
/// callers never need to guard construction sites on whether a status file
/// was configured.
/// </para>
///
/// <para>
/// <strong>Never write credential material into this stream.</strong> Every
/// event method below takes only identifiers, names, and counts — there is no
/// parameter shape that could carry a password, by construction.
/// </para>
/// </summary>
public sealed class SessionStatusWriter
{
private const int VocabularyVersion = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly string? _path;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
{
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// True when this writer has a configured path and will actually append
/// events. Lets a caller with an expensive report to build (e.g. the
/// roster projection) skip that work entirely when nobody configured a
/// status file for this session.
/// </summary>
public bool IsEnabled => _path is not null;
public void Started(string sessionId) =>
Write(new
{
v = VocabularyVersion,
e = "started",
t = Now(),
sessionId,
});
public void Connected(string sessionId) =>
Write(new
{
v = VocabularyVersion,
e = "connected",
t = Now(),
sessionId,
});
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
{
ArgumentNullException.ThrowIfNull(roster);
if (!IsEnabled)
return;
Write(new
{
v = VocabularyVersion,
e = "characterList",
t = Now(),
sessionId,
accountName = roster.AccountName,
slotCount = roster.SlotCount,
characters = roster.Entries
.Select(static entry => new
{
id = entry.Id,
name = entry.Name,
secondsGreyedOut = entry.SecondsGreyedOut,
})
.ToArray(),
});
}
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
Write(new
{
v = VocabularyVersion,
e = "enteredWorld",
t = Now(),
sessionId,
characterId,
characterName,
});
public void Disconnected(string sessionId, string reason) =>
Write(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason,
});
public void Exited(string sessionId, int code, string reason) =>
Write(new
{
v = VocabularyVersion,
e = "exited",
t = Now(),
sessionId,
code,
reason,
});
private string Now() =>
_timeProvider.GetUtcNow().ToString(
"O",
System.Globalization.CultureInfo.InvariantCulture);
private void Write<T>(T value)
{
if (_path is not { } path)
return;
string line = JsonSerializer.Serialize(value, JsonOptions);
lock (_gate)
{
using FileStream stream = new(
path,
FileMode.Append,
FileAccess.Write,
FileShare.Read);
using var writer = new StreamWriter(stream);
writer.WriteLine(line);
writer.Flush();
}
}
}