Files
stack/packages/tasks/src/requirements.mjs
T
jason.woltjeandClaude Opus 5.5 e918a915a8 tasks, bus, harness, seat: the approved requirement ids as data (#1584)
Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:49 -05:00

54 lines
2.9 KiB
JavaScript

import { readFileSync } from 'node:fs';
// Row 90 (#1584): the approved PRD's requirement ids as data, in ../requirements.json. The PRD
// (docs/prd/mosaic-stack.md) stays the reviewed source; tests/requirements.test.mjs fails unless
// the list holds its **REQ-...** ids in its order. A later PRD version is a new list in a reviewed
// commit. task.create refuses an id the list doesn't hold, and the broker's `requirements` verb
// returns the list to the sessions that create tasks. This module imports nothing from the bus,
// so the broker can load it. This function is the file's schema: it refuses any other shape.
export const REQUIREMENTS_FILE = new URL('../requirements.json', import.meta.url);
export const REQUIREMENT_ID = /^REQ-[A-Z]+-[1-9][0-9]*$/;
export class RequirementsError extends Error {}
const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const str = (x, max) => typeof x === 'string' && x.trim() !== '' && x.length <= max;
function exact(x, where, allowed) {
if (!plain(x)) throw new RequirementsError(`${where}: not an object`);
for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new RequirementsError(`${where}: unknown key ${k}`);
for (const k of allowed) if (!Object.hasOwn(x, k)) throw new RequirementsError(`${where}: missing ${k}`);
}
export function checkRequirements(list) {
exact(list, 'list', ['prd', 'version', 'requirements']);
if (!str(list.prd, 256) || !/^docs\/prd\/[a-z0-9-]+\.md$/.test(list.prd))
throw new RequirementsError('prd: not a docs/prd/*.md path');
if (typeof list.version !== 'string' || !/^[1-9][0-9]*\.[0-9]+$/.test(list.version))
throw new RequirementsError('version: not a version such as 1.0');
if (!Array.isArray(list.requirements) || !list.requirements.length)
throw new RequirementsError('requirements: not a nonempty list');
const seen = new Set();
for (const [i, r] of list.requirements.entries()) {
const at = `requirements[${i}]`;
exact(r, at, ['id', 'title']);
if (typeof r.id !== 'string' || !REQUIREMENT_ID.test(r.id)) throw new RequirementsError(`${at}.id: not a requirement id`);
if (seen.has(r.id)) throw new RequirementsError(`${at}.id: ${r.id} twice`);
seen.add(r.id);
if (!str(r.title, 120) || r.title.includes('\n')) throw new RequirementsError(`${at}.title: not one line of 120 characters or fewer`);
}
return list;
}
function freeze(x) {
if (x && typeof x === 'object') {
for (const v of Object.values(x)) freeze(v);
Object.freeze(x);
}
return x;
}
// The committed list, checked and frozen. A file that fails the check refuses to load.
export function loadRequirements(file = REQUIREMENTS_FILE) {
let list;
try {
list = JSON.parse(readFileSync(file, 'utf8'));
} catch (e) {
throw new RequirementsError(e instanceof SyntaxError ? 'requirements.json: not JSON' : `requirements.json: ${e.code ?? 'unreadable'}`);
}
return freeze(checkRequirements(list));
}