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

@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
/// legal no-ops.
/// </summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
/// Absent means load every discovered plugin (today's dev behavior);
/// LA5 wires this into an actual allow-list filter. Parsed and carried
/// here now so the session-config shape is stable before LA5 lands.
/// </summary>
public List<string>? Plugins { get; init; }
/// <summary>
/// Campaign LA slice LA1: ordered chat-typed strings run once the
/// session enters world. LA6 wires actual execution; parsed and carried
/// here now.
/// </summary>
public List<string>? LoginCommands { get; init; }
/// <summary>
/// Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
/// launch-contract default (500 ms) when the field is absent from the
/// document.
/// </summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>
/// Campaign LA slice LA1: absolute path for this session's status-event
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
/// is constructed for this session.
/// </summary>
public string? StatusFile { get; init; }
}
internal sealed class HeadlessEndpointDescriptor

View file

@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
}
ValidateCharacterOptions(session);
ValidateLaunchContractFields(session);
}
/// <summary>
/// Campaign LA slice LA1: validates the four new optional per-session
/// fields shared with the App session-config reader (see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
/// contract). All four stay optional; only their SHAPE is checked here
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
/// </summary>
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
{
if (session.Plugins is { } plugins)
{
foreach (string? plugin in plugins)
{
if (string.IsNullOrWhiteSpace(plugin))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' plugins entries must be non-empty strings.");
}
}
}
if (session.LoginCommandDelayMs < 0)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
}
if (session.StatusFile is not null
&& string.IsNullOrWhiteSpace(session.StatusFile))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
}
}
/// <summary>