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:
parent
8f1bf9b7b4
commit
7a90d348ad
149 changed files with 26 additions and 33 deletions
94
plugins/voyage/lib/validators/architecture-discovery.mjs
Normal file
94
plugins/voyage/lib/validators/architecture-discovery.mjs
Normal 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);
|
||||
}
|
||||
116
plugins/voyage/lib/validators/brief-validator.mjs
Normal file
116
plugins/voyage/lib/validators/brief-validator.mjs
Normal 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);
|
||||
}
|
||||
208
plugins/voyage/lib/validators/next-session-prompt-validator.mjs
Normal file
208
plugins/voyage/lib/validators/next-session-prompt-validator.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
plugins/voyage/lib/validators/plan-validator.mjs
Normal file
76
plugins/voyage/lib/validators/plan-validator.mjs
Normal 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);
|
||||
}
|
||||
106
plugins/voyage/lib/validators/progress-validator.mjs
Normal file
106
plugins/voyage/lib/validators/progress-validator.mjs
Normal 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);
|
||||
}
|
||||
109
plugins/voyage/lib/validators/research-validator.mjs
Normal file
109
plugins/voyage/lib/validators/research-validator.mjs
Normal 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);
|
||||
}
|
||||
109
plugins/voyage/lib/validators/review-validator.mjs
Normal file
109
plugins/voyage/lib/validators/review-validator.mjs
Normal 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);
|
||||
}
|
||||
117
plugins/voyage/lib/validators/session-state-validator.mjs
Normal file
117
plugins/voyage/lib/validators/session-state-validator.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue