Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
repositories/aaf-holdings/hq01/lib/missions/manager.ts
7.7 KB
import fs from "node:fs";
import path from "node:path";
import { listSessions } from "@/lib/sessions/manager";
import { ORGANIZATION } from "./config";
import { allowedNextStates, canTransition } from "./lifecycle";
import { missionCompletionBlocked } from "./objectives";
import {
  appendEvent,
  ensureMissionDirs,
  isValidMissionId,
  listMissionIds,
  listReports,
  nextMissionId,
  readHistory,
  readMission,
  reportStored,
  storeReport,
  writeMission,
} from "./store";
import type {
  CreateMissionInput,
  Mission,
  MissionEvent,
  MissionEventType,
  MissionStatus,
  MissionView,
  UpdateMissionInput,
} from "./types";

/**
 * The Mission Registry manager — the one place that creates, reads, updates, and
 * archives missions, and that maintains their append-only history. It also
 * collects execution reports from completed dispatched sessions into the owning
 * mission (PASS M0 report storage), reading sessions through the Session Manager
 * without reaching into its internals.
 */

export class MissionError extends Error {
  constructor(
    message: string,
    readonly status = 400,
  ) {
    super(message);
    this.name = "MissionError";
  }
}

function nowIso(): string {
  return new Date().toISOString();
}

function event(
  type: MissionEventType,
  extra: Partial<MissionEvent> = {},
): MissionEvent {
  return { type, at: nowIso(), ...extra };
}

/** Append a history event to a mission. */
export function recordEvent(id: string, ev: MissionEvent): void {
  appendEvent(id, ev);
}

export function createMission(input: CreateMissionInput): Mission {
  const title = input.title?.trim();
  if (!title) throw new MissionError("Title is required.");
  const description = input.description?.trim();
  if (!description) throw new MissionError("Description is required.");
  const product = input.product?.trim();
  if (!product) throw new MissionError("Product is required.");
  const repository = input.repository?.trim();
  if (!repository) throw new MissionError("Repository is required.");
  const owner = input.executive_owner?.trim();
  if (!owner) throw new MissionError("Executive owner is required.");
  if (!input.priority) throw new MissionError("Priority is required.");

  const id = nextMissionId();
  const created = nowIso();
  const mission: Mission = {
    id,
    title,
    description,
    organization: input.organization?.trim() || ORGANIZATION,
    company: input.company?.trim() || null,
    product,
    repository,
    executive_owner: owner,
    status: "Draft",
    priority: input.priority,
    created_at: created,
    updated_at: created,
  };

  ensureMissionDirs(id);
  writeMission(mission);
  recordEvent(id, event("mission_created", { detail: title }));
  recordEvent(id, event("executive_assigned", { detail: owner }));
  return mission;
}

export function getMission(id: string): Mission | null {
  if (!isValidMissionId(id)) return null;
  return readMission(id);
}

export function listMissions(): Mission[] {
  return listMissionIds()
    .map((id) => readMission(id))
    .filter((m): m is Mission => Boolean(m))
    .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
}

export function updateMission(id: string, patch: UpdateMissionInput): Mission {
  const mission = getMission(id);
  if (!mission) throw new MissionError("Mission not found.", 404);

  const prevOwner = mission.executive_owner;
  const next: Mission = { ...mission };
  if (patch.title !== undefined) next.title = patch.title.trim();
  if (patch.description !== undefined) next.description = patch.description.trim();
  if (patch.product !== undefined) next.product = patch.product.trim();
  if (patch.repository !== undefined) next.repository = patch.repository.trim();
  if (patch.company !== undefined) next.company = patch.company?.trim() || null;
  if (patch.priority !== undefined) next.priority = patch.priority;
  // Status is governed by transitionMission, never edited directly here.
  if (patch.executive_owner !== undefined) {
    next.executive_owner = patch.executive_owner?.trim() || null;
  }
  next.updated_at = nowIso();
  writeMission(next);

  recordEvent(id, event("mission_updated"));
  if (patch.executive_owner !== undefined && next.executive_owner !== prevOwner) {
    recordEvent(id, event("executive_assigned", { detail: next.executive_owner ?? "(unassigned)" }));
  }
  return next;
}

/**
 * Transition a mission to a new state, enforcing the lifecycle state machine.
 * Invalid transitions are rejected. Records an append-only state_changed event.
 */
export function transitionMission(
  id: string,
  to: MissionStatus,
  opts: { actor?: string; reason?: string } = {},
): Mission {
  const mission = getMission(id);
  if (!mission) throw new MissionError("Mission not found.", 404);
  const from = mission.status;
  if (!canTransition(from, to)) {
    const allowed = allowedNextStates(from);
    throw new MissionError(
      `Invalid transition: ${from} → ${to}. Allowed from ${from}: ${
        allowed.length ? allowed.join(", ") : "(none — terminal)"
      }.`,
      409,
    );
  }
  // Completion is gated by objectives (PASS M2): every objective must be done.
  if (to === "Completed" && missionCompletionBlocked(id)) {
    throw new MissionError(
      "Mission completion blocked. Incomplete Objectives remain.",
      409,
    );
  }
  const next: Mission = { ...mission, status: to, updated_at: nowIso() };
  writeMission(next);
  recordEvent(
    id,
    event("state_changed", {
      previous_state: from,
      new_state: to,
      actor: opts.actor ?? "CEO",
      reason: opts.reason,
      detail: `${from} → ${to}`,
    }),
  );
  return next;
}

/** Archive a mission (governed: only valid from Completed or Cancelled). */
export function archiveMission(id: string, actor = "CEO"): Mission {
  return transitionMission(id, "Archived", { actor });
}

/** Record that a dispatch began under this mission. */
export function recordDispatchStarted(
  id: string,
  opts: { sessionId: string; executiveId: string; instruction: string },
): void {
  recordEvent(
    id,
    event("dispatch_started", {
      session_id: opts.sessionId,
      by: opts.executiveId,
      detail: opts.instruction.slice(0, 140),
    }),
  );
}

/**
 * Collect reports from completed dispatched sessions belonging to this mission.
 * Idempotent: a session's report is stored once. Appends report_added and
 * dispatch_completed history the first time each report lands.
 */
export function collectReports(id: string): number {
  const mission = getMission(id);
  if (!mission) return 0;
  let collected = 0;

  const sessions = listSessions().filter((s) => s.mission_id === id);
  for (const s of sessions) {
    if (s.status !== "completed") continue;
    if (reportStored(id, s.id)) continue;

    const root = s.runtime_write_root || s.working_directory;
    const candidates = [
      path.join(root, "report.md"),
      path.join(root, "outputs", "report.md"),
    ];
    const found = candidates.find((p) => {
      try {
        return fs.statSync(p).isFile();
      } catch {
        return false;
      }
    });
    if (!found) continue;

    let content: string;
    try {
      content = fs.readFileSync(found, "utf8");
    } catch {
      continue;
    }
    storeReport(id, s.id, content);
    recordEvent(id, event("report_added", { session_id: s.id, report_id: s.id }));
    recordEvent(id, event("dispatch_completed", { session_id: s.id, by: s.executive }));
    collected += 1;
  }
  return collected;
}

/** Mission plus history and reports, collecting any newly-finished reports. */
export function getMissionView(id: string): MissionView | null {
  const mission = getMission(id);
  if (!mission) return null;
  collectReports(id);
  return {
    mission,
    history: readHistory(id).sort((a, b) => b.at.localeCompare(a.at)),
    reports: listReports(id),
  };
}

root · /srv/aaf