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, baseb8539480). 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 onc047d028plus 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]>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
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));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal } from './vikunja.mjs';
|
||||
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
|
||||
import { board, normal } from './sync.mjs';
|
||||
import { loadRequirements } from './requirements.mjs';
|
||||
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
|
||||
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
|
||||
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
|
||||
@@ -64,6 +65,8 @@ const sorted = (a) => [...a].sort((x, y) => x - y);
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
export function verbs(ctx) {
|
||||
const fail = (code) => refuse(code);
|
||||
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start.
|
||||
const requirements = new Set(loadRequirements().requirements.map((r) => r.id));
|
||||
const label = (id) => {
|
||||
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
|
||||
if (!ctx.labels.has(id)) fail('label-not-allowed');
|
||||
@@ -215,6 +218,7 @@ export function verbs(ctx) {
|
||||
plan.request = text(args.request, 64, 1);
|
||||
plan.requirement = text(args.requirement, 64);
|
||||
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
|
||||
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
|
||||
return plan;
|
||||
}
|
||||
case 'task.assign':
|
||||
|
||||
Reference in New Issue
Block a user