Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
repositories/aaf-holdings/hq01/lib/missions/objectives.ts
8.6 KB
import fs from "node:fs";
import path from "node:path";
import { missionDir, readMission } from "./store";
import type {
CreateObjectiveInput,
MissionProgress,
Objective,
ObjectiveEvent,
ObjectiveEventType,
ObjectiveStatus,
ObjectiveView,
UpdateObjectiveInput,
} from "./types";
/**
* Objectives (PASS M2) — measurable outcomes physically owned by a mission.
* Stored under each mission: MISSION-xxxxx/objectives/OBJECTIVE-000001/.
*
* Objectives are NOT work orders, assignments, or tasks, and they do NOT
* dispatch. They organize future execution and gate mission completion.
*/
export class ObjectiveError extends Error {
constructor(
message: string,
readonly status = 400,
) {
super(message);
this.name = "ObjectiveError";
}
}
const OID_RE = /^OBJECTIVE-\d{6}$/;
/** Statuses that count toward mission progress (active set). */
const ACTIVE_STATUSES: ObjectiveStatus[] = [
"Draft",
"Planned",
"Active",
"Blocked",
"Completed",
];
/** Statuses that block a mission from completing. */
const INCOMPLETE_STATUSES: ObjectiveStatus[] = ["Draft", "Planned", "Active", "Blocked"];
function nowIso(): string {
return new Date().toISOString();
}
function isValidObjectiveId(id: string): boolean {
return OID_RE.test(id);
}
function objectivesDir(missionId: string): string {
return path.join(missionDir(missionId), "objectives");
}
function objectiveDir(missionId: string, oid: string): string {
return path.join(objectivesDir(missionId), oid);
}
function objectiveJsonPath(missionId: string, oid: string): string {
return path.join(objectiveDir(missionId, oid), "objective.json");
}
function objHistoryLog(missionId: string, oid: string): string {
return path.join(objectiveDir(missionId, oid), "history", "log.jsonl");
}
function writeJsonAtomic(file: string, data: unknown): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
fs.renameSync(tmp, file);
}
function writeObjective(o: Objective): void {
writeJsonAtomic(objectiveJsonPath(o.mission_id, o.id), o);
}
function readObjective(missionId: string, oid: string): Objective | null {
if (!isValidObjectiveId(oid)) return null;
try {
return JSON.parse(
fs.readFileSync(objectiveJsonPath(missionId, oid), "utf8"),
) as Objective;
} catch {
return null;
}
}
function appendObjEvent(
missionId: string,
oid: string,
type: ObjectiveEventType,
extra: Partial<ObjectiveEvent> = {},
): void {
const file = objHistoryLog(missionId, oid);
fs.mkdirSync(path.dirname(file), { recursive: true });
const ev: ObjectiveEvent = { type, at: nowIso(), ...extra };
fs.appendFileSync(file, JSON.stringify(ev) + "\n", "utf8");
}
function readObjHistory(missionId: string, oid: string): ObjectiveEvent[] {
let raw: string;
try {
raw = fs.readFileSync(objHistoryLog(missionId, oid), "utf8");
} catch {
return [];
}
return raw
.split("\n")
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l) as ObjectiveEvent;
} catch {
return null;
}
})
.filter((e): e is ObjectiveEvent => Boolean(e));
}
function nextObjectiveId(missionId: string): string {
let max = 0;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(objectivesDir(missionId), { withFileTypes: true });
} catch {
/* none yet */
}
for (const e of entries) {
if (e.isDirectory() && isValidObjectiveId(e.name)) {
const n = Number(e.name.slice("OBJECTIVE-".length));
if (Number.isFinite(n) && n > max) max = n;
}
}
return `OBJECTIVE-${String(max + 1).padStart(6, "0")}`;
}
export function listObjectives(missionId: string): Objective[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(objectivesDir(missionId), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && isValidObjectiveId(e.name))
.map((e) => readObjective(missionId, e.name))
.filter((o): o is Objective => Boolean(o))
.sort((a, b) => a.id.localeCompare(b.id));
}
export function getObjective(missionId: string, oid: string): Objective | null {
return readObjective(missionId, oid);
}
export function getObjectiveView(
missionId: string,
oid: string,
): ObjectiveView | null {
const objective = readObjective(missionId, oid);
if (!objective) return null;
return {
objective,
history: readObjHistory(missionId, oid).sort((a, b) =>
b.at.localeCompare(a.at),
),
};
}
export function createObjective(
missionId: string,
input: CreateObjectiveInput,
): Objective {
if (!readMission(missionId)) throw new ObjectiveError("Mission not found.", 404);
const title = input.title?.trim();
if (!title) throw new ObjectiveError("Title is required.");
const description = input.description?.trim();
if (!description) throw new ObjectiveError("Description is required.");
const owner = input.owner_executive?.trim();
if (!owner) throw new ObjectiveError("Owner executive is required.");
if (!input.priority) throw new ObjectiveError("Priority is required.");
const id = nextObjectiveId(missionId);
const created = nowIso();
const objective: Objective = {
id,
title,
description,
mission_id: missionId,
owner_executive: owner,
status: "Draft",
priority: input.priority,
success_criteria: (input.success_criteria ?? [])
.map((s) => s.trim())
.filter(Boolean),
completion_percentage: 0,
created_at: created,
updated_at: created,
};
fs.mkdirSync(path.join(objectiveDir(missionId, id), "history"), {
recursive: true,
});
writeObjective(objective);
appendObjEvent(missionId, id, "objective_created", { detail: title });
appendObjEvent(missionId, id, "executive_changed", { detail: owner });
return objective;
}
export function updateObjective(
missionId: string,
oid: string,
patch: UpdateObjectiveInput,
): Objective {
const obj = readObjective(missionId, oid);
if (!obj) throw new ObjectiveError("Objective not found.", 404);
const prevOwner = obj.owner_executive;
const next: Objective = { ...obj };
if (patch.title !== undefined) next.title = patch.title.trim();
if (patch.description !== undefined) next.description = patch.description.trim();
if (patch.priority !== undefined) next.priority = patch.priority;
if (patch.success_criteria !== undefined) {
next.success_criteria = patch.success_criteria.map((s) => s.trim()).filter(Boolean);
}
if (patch.completion_percentage !== undefined) {
next.completion_percentage = Math.max(
0,
Math.min(100, Math.round(patch.completion_percentage)),
);
}
if (patch.owner_executive !== undefined) {
next.owner_executive = patch.owner_executive?.trim() || null;
}
next.updated_at = nowIso();
writeObjective(next);
appendObjEvent(missionId, oid, "objective_updated");
if (patch.owner_executive !== undefined && next.owner_executive !== prevOwner) {
appendObjEvent(missionId, oid, "executive_changed", {
detail: next.owner_executive ?? "(unassigned)",
});
}
return next;
}
/** Change an objective's status, recording a state_changed event. */
export function setObjectiveStatus(
missionId: string,
oid: string,
to: ObjectiveStatus,
opts: { actor?: string; reason?: string } = {},
): Objective {
const obj = readObjective(missionId, oid);
if (!obj) throw new ObjectiveError("Objective not found.", 404);
const from = obj.status;
const next: Objective = { ...obj, status: to, updated_at: nowIso() };
if (to === "Completed") next.completion_percentage = 100;
writeObjective(next);
appendObjEvent(missionId, oid, "state_changed", {
previous_state: from,
new_state: to,
actor: opts.actor ?? "CEO",
reason: opts.reason,
detail: `${from} → ${to}`,
});
return next;
}
/** Mission progress: completed / total over the active objective set. */
export function missionProgress(missionId: string): MissionProgress {
const objectives = listObjectives(missionId);
const active = objectives.filter((o) => ACTIVE_STATUSES.includes(o.status));
const completed = active.filter((o) => o.status === "Completed").length;
const total = active.length;
const percentage = total === 0 ? 0 : Math.round((completed / total) * 100);
return { total, completed, percentage };
}
/**
* Whether a mission is blocked from completing: true if any objective is still
* in an incomplete state (Draft/Planned/Active/Blocked). A mission with no such
* objectives is eligible (vacuously, if it has none at all).
*/
export function missionCompletionBlocked(missionId: string): boolean {
return listObjectives(missionId).some((o) =>
INCOMPLETE_STATUSES.includes(o.status),
);
}
root · /srv/aaf