feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]

Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-05 15:37:52 +02:00
commit 7a90d348ad
149 changed files with 26 additions and 33 deletions

View file

@ -0,0 +1,94 @@
// lib/validators/architecture-discovery.mjs
// EXTERNAL CONTRACT — drift-WARN, never drift-FAIL.
//
// The architecture/ directory is owned by the separate `ultra-cc-architect`
// plugin. ultraplan-local validates only DISCOVERY (file present at canonical
// path) and tolerates internal-format drift via warnings.
//
// Never read body content beyond first heading. Never assert frontmatter shape.
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { issue } from '../util/result.mjs';
const CANONICAL_OVERVIEW = 'overview.md';
const CANONICAL_GAPS = 'gaps.md';
const KNOWN_ALTERNATIVES = ['architecture-overview.md', 'overview.markdown', 'README.md'];
export function discoverArchitecture(projectDir) {
const archDir = projectDir ? join(projectDir, 'architecture') : null;
const result = {
found: false,
overview: null,
gaps: null,
looseFiles: [],
warnings: [],
};
if (!archDir || !existsSync(archDir) || !statSync(archDir).isDirectory()) {
return result;
}
const overviewPath = join(archDir, CANONICAL_OVERVIEW);
if (existsSync(overviewPath) && statSync(overviewPath).isFile()) {
result.found = true;
result.overview = overviewPath;
} else {
for (const alt of KNOWN_ALTERNATIVES) {
const altPath = join(archDir, alt);
if (existsSync(altPath) && statSync(altPath).isFile()) {
result.found = true;
result.overview = altPath;
result.warnings.push(issue(
'ARCH_NON_CANONICAL_OVERVIEW',
`Architecture file at non-canonical path: ${alt}`,
`Canonical contract is architecture/overview.md. The ultra-cc-architect plugin may have drifted; this is a warning, not a blocker.`,
));
break;
}
}
}
const gapsPath = join(archDir, CANONICAL_GAPS);
if (existsSync(gapsPath) && statSync(gapsPath).isFile()) result.gaps = gapsPath;
const all = readdirSync(archDir).filter(f => /\.md$/i.test(f));
result.looseFiles = all
.filter(f => f !== CANONICAL_OVERVIEW && f !== CANONICAL_GAPS && !KNOWN_ALTERNATIVES.includes(f))
.map(f => join(archDir, f));
if (result.looseFiles.length > 0) {
result.warnings.push(issue(
'ARCH_LOOSE_FILES',
`Found ${result.looseFiles.length} unrecognized architecture file(s)`,
`Architecture contract expects overview.md (+ optional gaps.md). Loose files may indicate format drift in ultra-cc-architect.`,
));
}
if (result.found && result.overview) {
try {
const text = readFileSync(result.overview, 'utf-8');
const firstHeading = text.match(/^#\s+(.+?)\s*$/m);
result.firstHeading = firstHeading ? firstHeading[1] : null;
} catch { /* ignore — only sniff */ }
}
return result;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const projectDir = process.argv[2];
const wantJson = process.argv.includes('--json');
if (!projectDir) {
process.stderr.write('Usage: architecture-discovery.mjs <project-dir> [--json]\n');
process.exit(2);
}
const r = discoverArchitecture(projectDir);
if (wantJson) {
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
} else {
process.stdout.write(`architecture-discovery: ${r.found ? 'FOUND' : 'NONE'} ${r.overview || projectDir}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(0);
}

View file

@ -0,0 +1,116 @@
// lib/validators/brief-validator.mjs
// Validate trekbrief frontmatter + body invariants.
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
export const BRIEF_REQUIRED_FRONTMATTER = ['type', 'brief_version', 'task', 'slug', 'research_topics', 'research_status'];
export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'project_dir', 'findings'];
export const BRIEF_TYPE_VALUES = Object.freeze(['trekbrief', 'trekreview']);
export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete', 'skipped'];
export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria'];
function getRequiredFields(type) {
return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER;
}
export function validateBriefContent(text, opts = {}) {
const strict = opts.strict !== false;
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const warnings = [];
for (const k of getRequiredFields(fm.type)) {
if (!(k in fm)) {
errors.push(issue('BRIEF_MISSING_FIELD', `Required frontmatter field missing: ${k}`));
}
}
if (fm.type !== undefined && !BRIEF_TYPE_VALUES.includes(fm.type)) {
errors.push(issue(
'BRIEF_WRONG_TYPE',
`frontmatter.type must be one of [${BRIEF_TYPE_VALUES.join(', ')}], got "${fm.type}"`,
));
}
if (fm.type === 'trekreview' && fm.findings !== undefined && !Array.isArray(fm.findings)) {
errors.push(issue(
'BRIEF_BAD_FINDINGS_TYPE',
'Field "findings" must be an array of finding-IDs for type:trekreview',
'Use block-style YAML: `findings:\\n - <id1>\\n - <id2>`',
));
}
if (fm.research_status !== undefined && !BRIEF_RESEARCH_STATUS_VALUES.includes(fm.research_status)) {
errors.push(issue(
'BRIEF_BAD_STATUS',
`research_status "${fm.research_status}" not in [${BRIEF_RESEARCH_STATUS_VALUES.join(', ')}]`,
));
}
if (typeof fm.research_topics === 'number' && fm.research_topics > 0 && fm.research_status === 'skipped') {
if (fm.brief_quality !== 'partial') {
errors.push(issue(
'BRIEF_STATE_INCOHERENT',
`research_topics=${fm.research_topics} but research_status=skipped`,
'Either set research_status to a real progress value, or mark brief_quality: partial.',
));
} else {
warnings.push(issue(
'BRIEF_PARTIAL_SKIPPED',
`Brief has unresolved research topics (${fm.research_topics}) but is partial`,
));
}
}
for (const section of BRIEF_BODY_SECTIONS) {
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
if (!re.test(body)) {
const issueObj = issue('BRIEF_MISSING_SECTION', `Required body section missing: ## ${section}`);
if (strict) errors.push(issueObj);
else warnings.push(issueObj);
}
}
if (typeof fm.brief_version === 'string') {
const m = fm.brief_version.match(/^(\d+)\.(\d+)$/);
if (!m) {
warnings.push(issue('BRIEF_VERSION_FORMAT', `brief_version "${fm.brief_version}" not in N.M form`));
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm, body } };
}
export function validateBrief(filePath, opts = {}) {
if (!existsSync(filePath)) return fail(issue('BRIEF_NOT_FOUND', `File not found: ${filePath}`));
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('BRIEF_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validateBriefContent(text, opts);
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: brief-validator.mjs [--soft] <brief.md>\n');
process.exit(2);
}
const r = validateBrief(filePath, { strict });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`brief-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -0,0 +1,208 @@
// lib/validators/next-session-prompt-validator.mjs
// Validate NEXT-SESSION-PROMPT.local.md frontmatter (Bug 3 contract).
//
// Producers (trekexecute Phase 8/2.55/4, trekendsession Phase 3) MUST write
// `produced_by:` and `produced_at:` (ISO-8601) frontmatter.
// Consumers (/trekcontinue Phase 1.5) compare two candidate files and refuse
// when producers disagree on a non-stale pair.
//
// Schema is forward-compatible: unknown frontmatter keys are tolerated.
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
import { splitFrontmatter, parseFrontmatter } from '../util/frontmatter.mjs';
export const NEXT_SESSION_PROMPT_REQUIRED_FIELDS = Object.freeze(['produced_by', 'produced_at']);
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export function validateNextSessionPromptContent(text) {
const split = splitFrontmatter(text);
if (!split.hasFrontmatter) {
return {
valid: true,
errors: [],
warnings: [issue(
'NEXT_SESSION_PROMPT_NO_FRONTMATTER',
'NEXT-SESSION-PROMPT.local.md has no YAML frontmatter',
'Producers should write produced_by and produced_at; legacy files are tolerated.',
)],
parsed: null,
};
}
const fm = parseFrontmatter(split.frontmatter);
if (!fm.valid) {
return { valid: false, errors: fm.errors, warnings: [], parsed: fm.parsed || null };
}
return validateNextSessionPromptObject(fm.parsed);
}
export function validateNextSessionPromptObject(parsed) {
const errors = [];
const warnings = [];
if (typeof parsed !== 'object' || parsed === null) {
return fail(issue('NEXT_SESSION_PROMPT_NOT_OBJECT', 'Frontmatter is not an object'));
}
for (const k of NEXT_SESSION_PROMPT_REQUIRED_FIELDS) {
if (!(k in parsed)) {
errors.push(issue(
'NEXT_SESSION_PROMPT_MISSING_FIELD',
`Required frontmatter field missing: ${k}`,
));
}
}
if (parsed.produced_at !== undefined) {
if (typeof parsed.produced_at !== 'string' || Number.isNaN(Date.parse(parsed.produced_at))) {
errors.push(issue(
'NEXT_SESSION_PROMPT_INVALID_TIMESTAMP',
`produced_at "${parsed.produced_at}" is not a valid ISO-8601 timestamp`,
));
}
}
if (parsed.produced_by !== undefined) {
if (typeof parsed.produced_by !== 'string' || parsed.produced_by.length === 0) {
errors.push(issue(
'NEXT_SESSION_PROMPT_INVALID_PRODUCER',
'produced_by must be a non-empty string',
));
}
}
return { valid: errors.length === 0, errors, warnings, parsed };
}
export function validateNextSessionPrompt(filePath) {
if (!existsSync(filePath)) {
return fail(issue('NEXT_SESSION_PROMPT_NOT_FOUND', `File not found: ${filePath}`));
}
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) {
return fail(issue('NEXT_SESSION_PROMPT_READ_ERROR', `Cannot read ${filePath}: ${e.message}`));
}
return validateNextSessionPromptContent(text);
}
/**
* Compare two NEXT-SESSION-PROMPT files for consistency.
* Optional state object enables state-anchored staleness check.
*
* @param {{path:string, parsed:object|null}} a
* @param {{path:string, parsed:object|null}} b
* @param {{state?: {updated_at?: string}, now?: number}} opts
*/
export function validateNextSessionPromptConsistency(a, b, opts = {}) {
const errors = [];
const warnings = [];
const now = typeof opts.now === 'number' ? opts.now : Date.now();
const stateUpdatedAt = opts.state && opts.state.updated_at
? Date.parse(opts.state.updated_at)
: NaN;
const stale = (cand) => {
if (!cand || !cand.parsed || !cand.parsed.produced_at) return false;
if (Number.isNaN(stateUpdatedAt)) return false;
const t = Date.parse(cand.parsed.produced_at);
if (Number.isNaN(t)) return false;
return t < stateUpdatedAt;
};
const aStale = stale(a);
const bStale = stale(b);
const aFm = a && a.parsed;
const bFm = b && b.parsed;
if (aFm && bFm) {
const producerMismatch = aFm.produced_by !== bFm.produced_by;
const bothFresh = !aStale && !bStale;
if (producerMismatch && bothFresh) {
errors.push(issue(
'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH',
`Frontmatter "produced_by" disagrees: "${aFm.produced_by}" (${a.path}) vs "${bFm.produced_by}" (${b.path})`,
'One file is stale or producers wrote conflicting frontmatter. Resolve manually.',
));
} else if (producerMismatch && (aStale || bStale)) {
const fresh = aStale ? b : a;
warnings.push(issue(
'NEXT_SESSION_PROMPT_STALE_IGNORED',
`Stale candidate ignored; using fresher prompt from ${fresh.path}`,
));
}
for (const cand of [a, b]) {
if (!cand || !cand.parsed || !cand.parsed.produced_at) continue;
const t = Date.parse(cand.parsed.produced_at);
if (Number.isNaN(t)) continue;
if (now - t > ONE_DAY_MS) {
warnings.push(issue(
'NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT',
`${cand.path} produced_at is more than 24h old (${cand.parsed.produced_at})`,
'Soft warning only. Resuming after a long pause is fine; verify state is still relevant.',
));
}
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { a: aFm || null, b: bFm || null } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const positionals = args.filter(a => !a.startsWith('--'));
const wantJson = args.includes('--json');
const consistency = args.includes('--consistency');
const stateIdx = args.indexOf('--state-file');
const stateFile = stateIdx >= 0 ? args[stateIdx + 1] : null;
function emit(r) {
if (wantJson) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`next-session-prompt-validator: ${r.valid ? 'PASS' : 'FAIL'}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}
if (consistency) {
const fileArgs = positionals;
if (fileArgs.length !== 2) {
process.stderr.write('Usage: next-session-prompt-validator.mjs --json --consistency <path-a> <path-b> [--state-file <state.json>]\n');
process.exit(2);
}
const [pathA, pathB] = fileArgs;
const ra = validateNextSessionPrompt(pathA);
const rb = validateNextSessionPrompt(pathB);
let stateObj = null;
if (stateFile) {
try {
const txt = readFileSync(stateFile, 'utf-8');
stateObj = JSON.parse(txt);
} catch (_e) {
stateObj = null;
}
}
const r = validateNextSessionPromptConsistency(
{ path: pathA, parsed: ra.parsed },
{ path: pathB, parsed: rb.parsed },
{ state: stateObj },
);
emit({
valid: r.valid && ra.valid !== false,
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
});
} else {
if (positionals.length !== 1) {
process.stderr.write('Usage: next-session-prompt-validator.mjs [--json] <NEXT-SESSION-PROMPT.local.md>\n');
process.exit(2);
}
const r = validateNextSessionPrompt(positionals[0]);
emit(r);
}
}

View file

@ -0,0 +1,76 @@
// lib/validators/plan-validator.mjs
// Wraps plan-schema (heading shape) + manifest-yaml (per-step Manifest blocks).
// This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator.
import { readFileSync, existsSync } from 'node:fs';
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { issue, fail } from '../util/result.mjs';
export function validatePlanContent(text, opts = {}) {
const strict = opts.strict !== false;
const headRes = validatePlanHeadings(text, { strict });
const errors = [...headRes.errors];
const warnings = [...headRes.warnings];
const steps = headRes.parsed?.steps || [];
const sections = sliceSteps(text);
const manRes = validateAllManifests(sections);
errors.push(...manRes.errors);
warnings.push(...manRes.warnings);
if (steps.length > 0 && manRes.parsed.length !== steps.length) {
errors.push(issue(
'PLAN_MANIFEST_COUNT_MISMATCH',
`Step count (${steps.length}) does not equal manifest count (${manRes.parsed.length})`,
));
}
const planVersion = extractPlanVersion(text);
if (planVersion === null) {
warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7'));
} else if (planVersion !== '1.7') {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`));
}
return {
valid: errors.length === 0,
errors,
warnings,
parsed: { steps, manifests: manRes.parsed, planVersion },
};
}
export function validatePlan(filePath, opts = {}) {
if (!existsSync(filePath)) return fail(issue('PLAN_NOT_FOUND', `File not found: ${filePath}`));
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('PLAN_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validatePlanContent(text, opts);
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] <plan.md>\n');
process.exit(2);
}
const r = validatePlan(filePath, { strict });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({
valid: r.valid,
errors: r.errors,
warnings: r.warnings,
steps: r.parsed?.steps?.length ?? 0,
planVersion: r.parsed?.planVersion ?? null,
}, null, 2) + '\n');
} else {
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -0,0 +1,106 @@
// lib/validators/progress-validator.mjs
// Validate progress.json shape + resume-readiness.
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps'];
export const PROGRESS_VALID_STATUSES = ['pending', 'in_progress', 'completed', 'failed', 'partial'];
export function validateProgressContent(jsonText, opts = {}) {
let parsed;
try { parsed = JSON.parse(jsonText); }
catch (e) {
return fail(issue('PROGRESS_PARSE_ERROR', `Cannot parse JSON: ${e.message}`));
}
return validateProgressObject(parsed, opts);
}
export function validateProgressObject(parsed, opts = {}) {
const errors = [];
const warnings = [];
if (typeof parsed !== 'object' || parsed === null) {
return fail(issue('PROGRESS_NOT_OBJECT', 'Progress payload is not an object'));
}
for (const k of PROGRESS_REQUIRED_TOP) {
if (!(k in parsed)) {
errors.push(issue('PROGRESS_MISSING_FIELD', `Required field missing: ${k}`));
}
}
if (parsed.schema_version !== undefined && parsed.schema_version !== '1') {
errors.push(issue('PROGRESS_SCHEMA_MISMATCH', `schema_version "${parsed.schema_version}" not supported (expected "1")`));
}
if (parsed.status !== undefined && !PROGRESS_VALID_STATUSES.includes(parsed.status)) {
errors.push(issue('PROGRESS_BAD_STATUS', `status "${parsed.status}" not in [${PROGRESS_VALID_STATUSES.join(', ')}]`));
}
if (typeof parsed.total_steps === 'number' && typeof parsed.current_step === 'number') {
if (parsed.current_step < 0 || parsed.current_step > parsed.total_steps) {
errors.push(issue('PROGRESS_STEP_RANGE', `current_step=${parsed.current_step} outside [0, ${parsed.total_steps}]`));
}
}
if (parsed.steps && typeof parsed.steps === 'object') {
const stepKeys = Object.keys(parsed.steps);
if (typeof parsed.total_steps === 'number' && stepKeys.length !== parsed.total_steps) {
warnings.push(issue(
'PROGRESS_STEP_COUNT_MISMATCH',
`total_steps=${parsed.total_steps} but steps map has ${stepKeys.length} entries`,
));
}
for (const k of stepKeys) {
const s = parsed.steps[k];
if (s === null || typeof s !== 'object') {
errors.push(issue('PROGRESS_STEP_SHAPE', `steps["${k}"] is not an object`));
continue;
}
if (s.status !== undefined && !['completed', 'in_progress', 'failed', 'pending', 'deferred', 'skipped'].includes(s.status)) {
warnings.push(issue('PROGRESS_STEP_BAD_STATUS', `steps["${k}"].status "${s.status}" unrecognized`));
}
}
}
return { valid: errors.length === 0, errors, warnings, parsed };
}
export function checkResumeReadiness(progressObj) {
const errors = [];
if (progressObj.status === 'completed') {
return { valid: false, errors: [issue('PROGRESS_ALREADY_DONE', 'Run is already completed; nothing to resume')], warnings: [], parsed: progressObj };
}
if (typeof progressObj.current_step !== 'number') {
errors.push(issue('PROGRESS_NO_CURRENT', 'No current_step in progress.json'));
}
return { valid: errors.length === 0, errors, warnings: [], parsed: progressObj };
}
export function validateProgress(filePath, opts = {}) {
if (!existsSync(filePath)) return fail(issue('PROGRESS_NOT_FOUND', `File not found: ${filePath}`));
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('PROGRESS_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
return validateProgressContent(text, opts);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: progress-validator.mjs [--quick] <progress.json>\n');
process.exit(2);
}
const r = validateProgress(filePath);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`progress-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -0,0 +1,109 @@
// lib/validators/research-validator.mjs
// Validate research-brief frontmatter + body invariants.
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
export const RESEARCH_REQUIRED_FRONTMATTER = ['type', 'created', 'question'];
export const RESEARCH_BODY_SECTIONS = ['Executive Summary', 'Dimensions'];
export function validateResearchContent(text, opts = {}) {
const strict = opts.strict !== false;
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const warnings = [];
for (const k of RESEARCH_REQUIRED_FRONTMATTER) {
if (!(k in fm)) errors.push(issue('RESEARCH_MISSING_FIELD', `Required frontmatter field missing: ${k}`));
}
if (fm.type !== undefined && fm.type !== 'trekresearch-brief') {
errors.push(issue('RESEARCH_WRONG_TYPE', `frontmatter.type must be "trekresearch-brief", got "${fm.type}"`));
}
if (fm.confidence !== undefined) {
if (typeof fm.confidence !== 'number' || fm.confidence < 0 || fm.confidence > 1) {
errors.push(issue('RESEARCH_BAD_CONFIDENCE', `confidence must be number in [0,1], got ${fm.confidence}`));
}
} else {
warnings.push(issue('RESEARCH_NO_CONFIDENCE', 'No confidence field — planner has no signal to weight findings'));
}
if (fm.dimensions !== undefined && (typeof fm.dimensions !== 'number' || fm.dimensions < 1)) {
errors.push(issue('RESEARCH_BAD_DIMENSIONS', `dimensions must be positive integer, got ${fm.dimensions}`));
}
for (const section of RESEARCH_BODY_SECTIONS) {
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
if (!re.test(body)) {
const issueObj = issue('RESEARCH_MISSING_SECTION', `Required body section missing: ## ${section}`);
if (strict) errors.push(issueObj);
else warnings.push(issueObj);
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm, body } };
}
export function validateResearch(filePath, opts = {}) {
if (!existsSync(filePath)) return fail(issue('RESEARCH_NOT_FOUND', `File not found: ${filePath}`));
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('RESEARCH_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validateResearchContent(text, opts);
return { ...r, parsed: { ...r.parsed, filePath } };
}
export function validateResearchDir(dirPath, opts = {}) {
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
return { valid: true, errors: [], warnings: [], parsed: { files: [] } };
}
const files = readdirSync(dirPath).filter(f => f.endsWith('.md')).sort();
const errors = [];
const warnings = [];
const results = [];
for (const f of files) {
const r = validateResearch(join(dirPath, f), opts);
for (const e of r.errors) errors.push(issue(e.code, `${f}: ${e.message}`, e.hint));
for (const w of r.warnings) warnings.push(issue(w.code, `${f}: ${w.message}`, w.hint));
results.push({ file: f, valid: r.valid });
}
return { valid: errors.length === 0, errors, warnings, parsed: { files: results } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const dirIdx = args.indexOf('--dir');
if (dirIdx >= 0 && args[dirIdx + 1]) {
const r = validateResearchDir(args[dirIdx + 1], { strict });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings, files: r.parsed.files }, null, 2) + '\n');
} else {
process.stdout.write(`research-validator (dir): ${r.valid ? 'PASS' : 'FAIL'} ${args[dirIdx + 1]}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: research-validator.mjs [--soft] <file.md> OR --dir <research-dir>\n');
process.exit(2);
}
const r = validateResearch(filePath, { strict });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`research-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -0,0 +1,109 @@
// lib/validators/review-validator.mjs
// Validate trekreview frontmatter + body invariants.
// 3-layer pattern (Content → File → CLI shim) mirroring brief-validator.
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
export const REVIEW_REQUIRED_FRONTMATTER = [
'type',
'review_version',
'task',
'slug',
'project_dir',
'brief_path',
'scope_sha_end',
'reviewed_files_count',
'findings',
];
export const REVIEW_BODY_SECTIONS = ['Executive Summary', 'Coverage', 'Remediation Summary'];
const HEX_ID_RE = /^[0-9a-f]{40}$/;
export function validateReviewContent(text, opts = {}) {
const strict = opts.strict !== false;
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const warnings = [];
for (const k of REVIEW_REQUIRED_FRONTMATTER) {
if (!(k in fm)) {
errors.push(issue('REVIEW_MISSING_FIELD', `Required frontmatter field missing: ${k}`));
}
}
if (fm.type !== undefined && fm.type !== 'trekreview') {
errors.push(issue('REVIEW_WRONG_TYPE', `frontmatter.type must be "trekreview", got "${fm.type}"`));
}
if (fm.findings !== undefined) {
if (!Array.isArray(fm.findings)) {
errors.push(issue(
'REVIEW_BAD_FINDINGS_TYPE',
`Field "findings" must be an array of finding-IDs, got ${typeof fm.findings}`,
'Use block-style YAML: `findings:\\n - <id1>\\n - <id2>`',
));
} else {
for (let i = 0; i < fm.findings.length; i++) {
const id = fm.findings[i];
if (typeof id !== 'string' || !HEX_ID_RE.test(id)) {
errors.push(issue(
'REVIEW_BAD_FINDING_ID',
`findings[${i}] is not a 40-char hex ID: ${JSON.stringify(id)}`,
));
}
}
}
}
for (const section of REVIEW_BODY_SECTIONS) {
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
if (!re.test(body)) {
const issueObj = issue('REVIEW_MISSING_SECTION', `Required body section missing: ## ${section}`);
if (strict) errors.push(issueObj);
else warnings.push(issueObj);
}
}
if (typeof fm.review_version === 'string') {
const m = fm.review_version.match(/^(\d+)\.(\d+)$/);
if (!m) {
warnings.push(issue('REVIEW_VERSION_FORMAT', `review_version "${fm.review_version}" not in N.M form`));
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm, body } };
}
export function validateReview(filePath, opts = {}) {
if (!existsSync(filePath)) return fail(issue('REVIEW_NOT_FOUND', `File not found: ${filePath}`));
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('REVIEW_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validateReviewContent(text, opts);
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: review-validator.mjs [--soft] [--json] <review.md>\n');
process.exit(2);
}
const r = validateReview(filePath, { strict });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`review-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -0,0 +1,117 @@
// lib/validators/session-state-validator.mjs
// Validate .session-state.local.json — the contract consumed by /trekcontinue.
// Schema v1 documented in docs/HANDOVER-CONTRACTS.md (Handover 7).
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
export const SESSION_STATE_REQUIRED_TOP = [
'schema_version',
'project',
'next_session_brief_path',
'next_session_label',
'status',
'updated_at',
];
// All five statuses parse as valid; `completed` emits a warning that the
// session is not resumable. Unknown statuses fail.
export const SESSION_STATE_VALID_STATUSES = ['in_progress', 'partial', 'failed', 'stopped', 'completed'];
// Statuses that /trekcontinue can resume from. `completed` is intentionally
// excluded — running trekcontinue on a completed project should signal "no
// further sessions to resume", not load stale context.
export const SESSION_STATE_RESUMABLE_STATUSES = ['in_progress', 'partial', 'failed', 'stopped'];
export function validateSessionStateContent(jsonText, opts = {}) {
let parsed;
try { parsed = JSON.parse(jsonText); }
catch (e) {
return fail(issue('SESSION_STATE_PARSE_ERROR', `Cannot parse JSON: ${e.message}`));
}
return validateSessionStateObject(parsed, opts);
}
export function validateSessionStateObject(parsed, opts = {}) {
const errors = [];
const warnings = [];
if (typeof parsed !== 'object' || parsed === null) {
return fail(issue('SESSION_STATE_NOT_OBJECT', 'Session-state payload is not an object'));
}
for (const k of SESSION_STATE_REQUIRED_TOP) {
if (!(k in parsed)) {
errors.push(issue('SESSION_STATE_MISSING_FIELD', `Required field missing: ${k}`));
}
}
if (parsed.schema_version !== undefined && parsed.schema_version !== 1) {
errors.push(issue(
'SESSION_STATE_SCHEMA_MISMATCH',
`schema_version ${JSON.stringify(parsed.schema_version)} not supported (expected 1)`,
));
}
if (parsed.status !== undefined) {
if (!SESSION_STATE_VALID_STATUSES.includes(parsed.status)) {
errors.push(issue(
'SESSION_STATE_INVALID_STATUS',
`status "${parsed.status}" not in [${SESSION_STATE_VALID_STATUSES.join(', ')}]`,
));
} else if (parsed.status === 'completed') {
warnings.push(issue(
'SESSION_STATE_NOT_RESUMABLE',
'status "completed" — project is done; no further sessions to resume',
));
}
}
if (parsed.next_session_brief_path !== undefined) {
if (typeof parsed.next_session_brief_path !== 'string' || parsed.next_session_brief_path.length === 0) {
errors.push(issue('SESSION_STATE_INVALID_PATH', 'next_session_brief_path must be a non-empty string'));
}
}
if (parsed.updated_at !== undefined) {
if (typeof parsed.updated_at !== 'string' || Number.isNaN(Date.parse(parsed.updated_at))) {
errors.push(issue('SESSION_STATE_INVALID_TIMESTAMP', `updated_at "${parsed.updated_at}" is not a valid ISO-8601 timestamp`));
}
}
// Forward-compat: unknown top-level keys are tolerated silently.
// This protects future graceful-handoff v2.2 dual-writes that emit
// additional fields (branch, git_status, committed_by, ...).
return { valid: errors.length === 0, errors, warnings, parsed };
}
export function validateSessionState(filePath, opts = {}) {
if (!existsSync(filePath)) {
return fail(issue('SESSION_STATE_NOT_FOUND', `File not found: ${filePath}`));
}
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) {
return fail(issue('SESSION_STATE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`));
}
return validateSessionStateContent(text, opts);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: session-state-validator.mjs [--json] <.session-state.local.json>\n');
process.exit(2);
}
const r = validateSessionState(filePath);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`session-state-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}