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
127
plugins/voyage/lib/parsers/arg-parser.mjs
Normal file
127
plugins/voyage/lib/parsers/arg-parser.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// lib/parsers/arg-parser.mjs
|
||||
// Parse $ARGUMENTS strings for the four voyage commands.
|
||||
//
|
||||
// Each command has its own valid-flag set; passing flags from another command
|
||||
// produces an `unknown_flags` array but does not error — the caller decides.
|
||||
|
||||
const FLAG_SCHEMA = {
|
||||
trekbrief: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: [],
|
||||
aliases: {},
|
||||
},
|
||||
trekresearch: {
|
||||
boolean: ['--quick', '--local', '--external', '--fg'],
|
||||
valued: ['--project'],
|
||||
aliases: {},
|
||||
},
|
||||
trekplan: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: ['--project', '--brief', '--export', '--decompose'],
|
||||
multi: ['--research'],
|
||||
aliases: {},
|
||||
},
|
||||
trekexecute: {
|
||||
boolean: ['--resume', '--dry-run', '--validate', '--fg'],
|
||||
valued: ['--project', '--step', '--session'],
|
||||
aliases: {},
|
||||
},
|
||||
trekreview: {
|
||||
boolean: ['--quick', '--fg', '--dry-run', '--validate'],
|
||||
valued: ['--project', '--since'],
|
||||
aliases: {},
|
||||
},
|
||||
trekcontinue: {
|
||||
boolean: ['--help', '--cleanup', '--confirm', '--dry-run'],
|
||||
valued: [],
|
||||
aliases: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} argString Raw $ARGUMENTS as the command sees it.
|
||||
* @param {keyof FLAG_SCHEMA} command
|
||||
* @returns {{
|
||||
* command: string,
|
||||
* flags: Record<string, true | string | string[]>,
|
||||
* positional: string[],
|
||||
* unknown: string[],
|
||||
* errors: Array<{code: string, message: string}>,
|
||||
* }}
|
||||
*/
|
||||
export function parseArgs(argString, command) {
|
||||
const schema = FLAG_SCHEMA[command];
|
||||
if (!schema) {
|
||||
return {
|
||||
command,
|
||||
flags: {},
|
||||
positional: [],
|
||||
unknown: [],
|
||||
errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(argString);
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
const unknown = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const tok = tokens[i];
|
||||
|
||||
if (!tok.startsWith('--')) {
|
||||
positional.push(tok);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.boolean.includes(tok)) {
|
||||
flags[tok] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.valued.includes(tok)) {
|
||||
const next = tokens[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires a value` });
|
||||
} else {
|
||||
flags[tok] = next;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.multi && schema.multi.includes(tok)) {
|
||||
const collected = [];
|
||||
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
|
||||
collected.push(tokens[i + 1]);
|
||||
i++;
|
||||
}
|
||||
if (collected.length === 0) {
|
||||
errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires at least one value` });
|
||||
} else {
|
||||
flags[tok] = collected;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
unknown.push(tok);
|
||||
}
|
||||
|
||||
return { command, flags, positional, unknown, errors };
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
if (typeof s !== 'string') return [];
|
||||
const trimmed = s.trim();
|
||||
if (trimmed === '') return [];
|
||||
const out = [];
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(trimmed)) !== null) {
|
||||
out.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { FLAG_SCHEMA };
|
||||
48
plugins/voyage/lib/parsers/bash-normalize.mjs
Normal file
48
plugins/voyage/lib/parsers/bash-normalize.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// lib/parsers/bash-normalize.mjs
|
||||
// Bash-evasion normalization, lifted from hooks/scripts/pre-bash-executor.mjs.
|
||||
//
|
||||
// Source: ../../hooks/scripts/pre-bash-executor.mjs (lines 22-45) — verbatim
|
||||
// extraction so the runtime hook and the test suite share one implementation.
|
||||
// The hook still inlines a copy because it cannot import from outside the
|
||||
// plugin distribution at this time; both copies must stay in sync.
|
||||
|
||||
/**
|
||||
* Strip bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
* Used to canonicalize a command before running denylist regex over it.
|
||||
*/
|
||||
export function normalizeBashExpansion(cmd) {
|
||||
if (typeof cmd !== 'string' || cmd === '') return '';
|
||||
|
||||
let result = cmd
|
||||
.replace(/''/g, '')
|
||||
.replace(/""/g, '')
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes and collapse whitespace.
|
||||
*/
|
||||
export function normalizeCommand(cmd) {
|
||||
if (typeof cmd !== 'string') return '';
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canonicalization pipeline used by hooks before pattern matching.
|
||||
*/
|
||||
export function canonicalize(cmd) {
|
||||
return normalizeCommand(normalizeBashExpansion(cmd));
|
||||
}
|
||||
54
plugins/voyage/lib/parsers/finding-id.mjs
Normal file
54
plugins/voyage/lib/parsers/finding-id.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// lib/parsers/finding-id.mjs
|
||||
// Stable finding-ID for /trekreview v1.0.
|
||||
//
|
||||
// id = sha1(file:line:rule_key) → 40-char hex.
|
||||
// Same input always produces same output (determinism floor SC4).
|
||||
// node:crypto is built-in (zero-deps invariant).
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const HEX_RE = /^[0-9a-f]{40}$/;
|
||||
|
||||
/**
|
||||
* Compute a stable 40-char hex finding-ID.
|
||||
* @param {string} filePath — relative path (caller normalizes if needed)
|
||||
* @param {number|string} line — 1-based line number; coerced to string
|
||||
* @param {string} ruleKey — must be a non-empty string from RULE_KEYS
|
||||
* @returns {string} 40-char lowercase hex
|
||||
* @throws {TypeError} on bad input
|
||||
*/
|
||||
export function computeFindingId(filePath, line, ruleKey) {
|
||||
if (typeof filePath !== 'string' || filePath.length === 0) {
|
||||
throw new TypeError('computeFindingId: filePath must be a non-empty string');
|
||||
}
|
||||
if (line === null || line === undefined) {
|
||||
throw new TypeError('computeFindingId: line must be a number or numeric string');
|
||||
}
|
||||
if (typeof line === 'number') {
|
||||
if (!Number.isFinite(line)) {
|
||||
throw new TypeError('computeFindingId: line must be finite');
|
||||
}
|
||||
} else if (typeof line === 'string') {
|
||||
if (line.length === 0) {
|
||||
throw new TypeError('computeFindingId: line must not be empty string');
|
||||
}
|
||||
} else {
|
||||
throw new TypeError('computeFindingId: line must be a number or numeric string');
|
||||
}
|
||||
if (typeof ruleKey !== 'string' || ruleKey.length === 0) {
|
||||
throw new TypeError('computeFindingId: ruleKey must be a non-empty string');
|
||||
}
|
||||
|
||||
const composite = `${filePath}:${line}:${ruleKey}`;
|
||||
return createHash('sha1').update(composite).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a finding-ID's shape (40-char lowercase hex).
|
||||
* @param {string} id
|
||||
* @returns {{valid: boolean}}
|
||||
*/
|
||||
export function parseFindingId(id) {
|
||||
if (typeof id !== 'string') return { valid: false };
|
||||
return { valid: HEX_RE.test(id) };
|
||||
}
|
||||
41
plugins/voyage/lib/parsers/jaccard.mjs
Normal file
41
plugins/voyage/lib/parsers/jaccard.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// lib/parsers/jaccard.mjs
|
||||
// Jaccard similarity for SC4 determinism floor.
|
||||
//
|
||||
// jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
||||
// Inputs are arrays of strings; deduplicated internally.
|
||||
// Both empty → 1.0 (vacuously identical). One empty → 0.0.
|
||||
|
||||
/**
|
||||
* Compute Jaccard similarity between two string sets.
|
||||
* @param {string[]} setA
|
||||
* @param {string[]} setB
|
||||
* @returns {number} similarity in [0, 1]
|
||||
*/
|
||||
export function jaccardSimilarity(setA, setB) {
|
||||
if (!Array.isArray(setA) || !Array.isArray(setB)) {
|
||||
throw new TypeError('jaccardSimilarity: both inputs must be arrays');
|
||||
}
|
||||
const a = new Set(setA);
|
||||
const b = new Set(setB);
|
||||
if (a.size === 0 && b.size === 0) return 1.0;
|
||||
if (a.size === 0 || b.size === 0) return 0.0;
|
||||
|
||||
let intersection = 0;
|
||||
for (const x of a) {
|
||||
if (b.has(x)) intersection += 1;
|
||||
}
|
||||
const union = a.size + b.size - intersection;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a similarity meets a threshold.
|
||||
* @param {number} similarity
|
||||
* @param {number} threshold
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function meetsThreshold(similarity, threshold) {
|
||||
if (typeof similarity !== 'number' || typeof threshold !== 'number') return false;
|
||||
if (!Number.isFinite(similarity) || !Number.isFinite(threshold)) return false;
|
||||
return similarity >= threshold;
|
||||
}
|
||||
144
plugins/voyage/lib/parsers/manifest-yaml.mjs
Normal file
144
plugins/voyage/lib/parsers/manifest-yaml.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// lib/parsers/manifest-yaml.mjs
|
||||
// Extract the `manifest:` YAML block from each step body.
|
||||
//
|
||||
// Plan v1.7 contract: every step has a fenced ```yaml ... ``` block whose
|
||||
// top-level key is `manifest:` and which contains the keys:
|
||||
// expected_paths, min_file_count, commit_message_pattern, bash_syntax_check,
|
||||
// forbidden_paths, must_contain.
|
||||
|
||||
import { issue, ok, fail } from '../util/result.mjs';
|
||||
import { parseFrontmatter } from '../util/frontmatter.mjs';
|
||||
|
||||
const FENCED_YAML_RE = /```ya?ml\s*\n([\s\S]*?)\n[ \t]*```/g;
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
'expected_paths',
|
||||
'min_file_count',
|
||||
'commit_message_pattern',
|
||||
'bash_syntax_check',
|
||||
'forbidden_paths',
|
||||
'must_contain',
|
||||
];
|
||||
|
||||
// Optional manifest keys (plan-v2 Step 4). Absence == false.
|
||||
// `skip_commit_check`: opt out of the per-step commit assertion (e.g. memory-only steps).
|
||||
// `memory_write` : marks a step that writes to ~/.claude/projects/.../memory/
|
||||
// so the executor can route it through the memory truth gate.
|
||||
const OPTIONAL_KEYS = [
|
||||
'skip_commit_check',
|
||||
'memory_write',
|
||||
];
|
||||
|
||||
const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS);
|
||||
|
||||
export { OPTIONAL_KEYS };
|
||||
|
||||
/**
|
||||
* Extract the first fenced YAML block whose first non-blank line begins with
|
||||
* `manifest:`.
|
||||
* @returns {string|null} Inner YAML body without the leading `manifest:` line.
|
||||
*/
|
||||
export function extractManifestYaml(stepBody) {
|
||||
if (typeof stepBody !== 'string') return null;
|
||||
FENCED_YAML_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = FENCED_YAML_RE.exec(stepBody)) !== null) {
|
||||
const block = m[1];
|
||||
const firstNonBlank = block.split(/\r?\n/).find(l => l.trim() !== '');
|
||||
if (firstNonBlank && /^manifest\s*:/.test(firstNonBlank.trim())) {
|
||||
const after = block.replace(/^[\s\S]*?manifest[ \t]*:[ \t]*\n?/, '');
|
||||
return after;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single step's manifest into an object.
|
||||
* Reuses the frontmatter parser (same restricted YAML subset).
|
||||
* @returns {import('../util/result.mjs').Result}
|
||||
*/
|
||||
export function parseManifest(stepBody) {
|
||||
const yamlText = extractManifestYaml(stepBody);
|
||||
if (yamlText === null) {
|
||||
return fail(issue('MANIFEST_MISSING', 'No `manifest:` YAML block found in step body'));
|
||||
}
|
||||
const dedented = dedent(yamlText);
|
||||
const result = parseFrontmatter(dedented);
|
||||
if (!result.valid) return result;
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const parsed = result.parsed || {};
|
||||
|
||||
for (const k of REQUIRED_KEYS) {
|
||||
if (!(k in parsed)) {
|
||||
errors.push(issue('MANIFEST_MISSING_KEY', `Manifest is missing required key: ${k}`));
|
||||
}
|
||||
}
|
||||
|
||||
if ('commit_message_pattern' in parsed) {
|
||||
const pat = parsed.commit_message_pattern;
|
||||
if (typeof pat !== 'string') {
|
||||
errors.push(issue('MANIFEST_PATTERN_TYPE', 'commit_message_pattern must be a string'));
|
||||
} else {
|
||||
try { new RegExp(pat); }
|
||||
catch (e) {
|
||||
errors.push(issue('MANIFEST_PATTERN_INVALID', `commit_message_pattern is not a valid regex: ${e.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ('expected_paths' in parsed && !Array.isArray(parsed.expected_paths)) {
|
||||
errors.push(issue('MANIFEST_PATHS_TYPE', 'expected_paths must be a list'));
|
||||
}
|
||||
|
||||
if ('min_file_count' in parsed && typeof parsed.min_file_count !== 'number') {
|
||||
errors.push(issue('MANIFEST_COUNT_TYPE', 'min_file_count must be a number'));
|
||||
}
|
||||
|
||||
for (const k of OPTIONAL_BOOLEAN_KEYS) {
|
||||
if (k in parsed) {
|
||||
if (typeof parsed[k] !== 'boolean') {
|
||||
errors.push(issue(
|
||||
'MANIFEST_OPTIONAL_TYPE',
|
||||
`${k} must be boolean if present (got ${typeof parsed[k]})`,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
parsed[k] = false; // default: absence == false
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
||||
function dedent(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const indents = lines
|
||||
.filter(l => l.trim() !== '')
|
||||
.map(l => (l.match(/^(\s*)/) || ['', ''])[1].length);
|
||||
if (indents.length === 0) return text;
|
||||
const min = Math.min(...indents);
|
||||
if (min === 0) return text;
|
||||
return lines.map(l => l.slice(min)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every step in a parsed plan has a manifest.
|
||||
* @param {Array<{n: number, body: string}>} steps
|
||||
* @returns {import('../util/result.mjs').Result}
|
||||
*/
|
||||
export function validateAllManifests(steps) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const parsed = [];
|
||||
for (const s of steps) {
|
||||
const r = parseManifest(s.body);
|
||||
if (!r.valid) {
|
||||
for (const e of r.errors) errors.push(issue(e.code, `Step ${s.n}: ${e.message}`, e.hint));
|
||||
}
|
||||
parsed.push({ n: s.n, manifest: r.parsed, valid: r.valid });
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
126
plugins/voyage/lib/parsers/plan-schema.mjs
Normal file
126
plugins/voyage/lib/parsers/plan-schema.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// lib/parsers/plan-schema.mjs
|
||||
// Plan v1.7 schema parser — heading shape detection.
|
||||
//
|
||||
// The canonical step heading is `### Step N: <title>` (literal colon-space).
|
||||
// Forbidden narrative drift formats (introduced in v1.8.0 to defend against
|
||||
// Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`.
|
||||
//
|
||||
// This module extracts step boundaries; per-step body parsing lives elsewhere.
|
||||
|
||||
import { ok, fail, issue } from '../util/result.mjs';
|
||||
|
||||
export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m;
|
||||
export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm;
|
||||
export const FORBIDDEN_HEADING_REGEX = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/m;
|
||||
export const FORBIDDEN_HEADING_GLOBAL = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/gm;
|
||||
export const PLAN_VERSION_REGEX = /^plan_version:\s*['"]?([\d.]+)['"]?/m;
|
||||
|
||||
/**
|
||||
* Find all step heading positions in plan text.
|
||||
* @returns {Array<{n: number, title: string, line: number, offset: number}>}
|
||||
*/
|
||||
export function findSteps(text) {
|
||||
if (typeof text !== 'string') return [];
|
||||
const out = [];
|
||||
STEP_HEADING_GLOBAL.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = STEP_HEADING_GLOBAL.exec(text)) !== null) {
|
||||
const offset = m.index;
|
||||
const line = text.slice(0, offset).split(/\r?\n/).length;
|
||||
out.push({ n: Number.parseInt(m[1], 10), title: m[2].trim(), line, offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find forbidden narrative-drift heading occurrences (Fase/Phase/Stage/Steg N).
|
||||
* @returns {Array<{form: string, line: number, offset: number, raw: string}>}
|
||||
*/
|
||||
export function findForbiddenHeadings(text) {
|
||||
if (typeof text !== 'string') return [];
|
||||
const out = [];
|
||||
FORBIDDEN_HEADING_GLOBAL.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = FORBIDDEN_HEADING_GLOBAL.exec(text)) !== null) {
|
||||
const offset = m.index;
|
||||
const line = text.slice(0, offset).split(/\r?\n/).length;
|
||||
const raw = m[0];
|
||||
out.push({ form: raw, line, offset, raw });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice plan text into per-step sections.
|
||||
* @returns {Array<{n: number, title: string, body: string, line: number}>}
|
||||
*/
|
||||
export function sliceSteps(text) {
|
||||
const heads = findSteps(text);
|
||||
const sections = [];
|
||||
for (let i = 0; i < heads.length; i++) {
|
||||
const start = heads[i].offset;
|
||||
const end = i + 1 < heads.length ? heads[i + 1].offset : text.length;
|
||||
const block = text.slice(start, end);
|
||||
sections.push({
|
||||
n: heads[i].n,
|
||||
title: heads[i].title,
|
||||
body: block,
|
||||
line: heads[i].line,
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `plan_version: X.Y` from frontmatter or doc body.
|
||||
*/
|
||||
export function extractPlanVersion(text) {
|
||||
const m = typeof text === 'string' ? text.match(PLAN_VERSION_REGEX) : null;
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate plan structure at the heading level.
|
||||
* Strict mode: forbidden-heading count > 0 → error. Step numbers must be 1..N contiguous.
|
||||
* @returns {import('../util/result.mjs').Result}
|
||||
*/
|
||||
export function validatePlanHeadings(text, opts = {}) {
|
||||
const strict = opts.strict !== false;
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
return fail(issue('PLAN_INPUT', 'Plan text is not a string'));
|
||||
}
|
||||
|
||||
const forbidden = findForbiddenHeadings(text);
|
||||
if (forbidden.length > 0) {
|
||||
const list = forbidden.map(f => `line ${f.line}: ${f.raw}`).join('; ');
|
||||
const errorIssue = issue(
|
||||
'PLAN_FORBIDDEN_HEADING',
|
||||
`Found ${forbidden.length} forbidden narrative-drift heading(s): ${list}`,
|
||||
'Use canonical "### Step N: <title>". Forbidden forms: Fase/Phase/Stage/Steg.',
|
||||
);
|
||||
if (strict) errors.push(errorIssue);
|
||||
else warnings.push(errorIssue);
|
||||
}
|
||||
|
||||
const steps = findSteps(text);
|
||||
if (steps.length === 0) {
|
||||
errors.push(issue('PLAN_NO_STEPS', 'No step headings found', 'Expected at least one "### Step 1: <title>".'));
|
||||
} else {
|
||||
const numbers = steps.map(s => s.n);
|
||||
for (let i = 0; i < numbers.length; i++) {
|
||||
if (numbers[i] !== i + 1) {
|
||||
errors.push(issue(
|
||||
'PLAN_STEP_NUMBERING',
|
||||
`Step numbering breaks at position ${i + 1} (got Step ${numbers[i]})`,
|
||||
'Steps must be 1..N contiguous and ordered.',
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } };
|
||||
}
|
||||
106
plugins/voyage/lib/parsers/project-discovery.mjs
Normal file
106
plugins/voyage/lib/parsers/project-discovery.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// lib/parsers/project-discovery.mjs
|
||||
// Discover ultra-suite artifacts inside a project directory.
|
||||
//
|
||||
// Layout (post-v3.0.0 project-directory contract):
|
||||
// .claude/projects/<YYYY-MM-DD>-<slug>/
|
||||
// brief.md
|
||||
// research/<NN>-<slug>.md (sorted by filename)
|
||||
// architecture/overview.md (opt-in, owned by separate ultra-cc-architect plugin)
|
||||
// plan.md
|
||||
// progress.json
|
||||
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* projectDir: string,
|
||||
* brief: string|null,
|
||||
* research: string[],
|
||||
* architecture: { overview: string|null, gaps: string|null, looseFiles: string[] },
|
||||
* plan: string|null,
|
||||
* progress: string|null,
|
||||
* review: string|null,
|
||||
* }} ProjectArtifacts
|
||||
*/
|
||||
|
||||
/** @returns {ProjectArtifacts} */
|
||||
export function discoverProject(projectDir) {
|
||||
const out = {
|
||||
projectDir,
|
||||
brief: null,
|
||||
research: [],
|
||||
architecture: { overview: null, gaps: null, looseFiles: [] },
|
||||
plan: null,
|
||||
progress: null,
|
||||
review: null,
|
||||
};
|
||||
|
||||
if (!projectDir || !existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const briefPath = join(projectDir, 'brief.md');
|
||||
if (existsSync(briefPath) && statSync(briefPath).isFile()) out.brief = briefPath;
|
||||
|
||||
const planPath = join(projectDir, 'plan.md');
|
||||
if (existsSync(planPath) && statSync(planPath).isFile()) out.plan = planPath;
|
||||
|
||||
const progressPath = join(projectDir, 'progress.json');
|
||||
if (existsSync(progressPath) && statSync(progressPath).isFile()) out.progress = progressPath;
|
||||
|
||||
const reviewPath = join(projectDir, 'review.md');
|
||||
if (existsSync(reviewPath) && statSync(reviewPath).isFile()) out.review = reviewPath;
|
||||
|
||||
const researchDir = join(projectDir, 'research');
|
||||
if (existsSync(researchDir) && statSync(researchDir).isDirectory()) {
|
||||
out.research = readdirSync(researchDir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort()
|
||||
.map(f => join(researchDir, f));
|
||||
}
|
||||
|
||||
const archDir = join(projectDir, 'architecture');
|
||||
if (existsSync(archDir) && statSync(archDir).isDirectory()) {
|
||||
const overviewPath = join(archDir, 'overview.md');
|
||||
const gapsPath = join(archDir, 'gaps.md');
|
||||
if (existsSync(overviewPath)) out.architecture.overview = overviewPath;
|
||||
if (existsSync(gapsPath)) out.architecture.gaps = gapsPath;
|
||||
const all = readdirSync(archDir).filter(f => f.endsWith('.md'));
|
||||
out.architecture.looseFiles = all
|
||||
.filter(f => f !== 'overview.md' && f !== 'gaps.md')
|
||||
.map(f => join(archDir, f));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that artifact set is consistent for a given pipeline phase.
|
||||
* Phase = 'brief' | 'research' | 'plan' | 'execute' | 'review'.
|
||||
*/
|
||||
export function checkPhaseRequirements(artifacts, phase) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (phase === 'research' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'research phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'plan' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'plan phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'execute' && !artifacts.plan) {
|
||||
errors.push({ code: 'PROJECT_NO_PLAN', message: 'execute phase requires plan.md' });
|
||||
}
|
||||
if (phase === 'review') {
|
||||
if (!artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'review phase requires brief.md' });
|
||||
}
|
||||
if (!artifacts.progress) {
|
||||
warnings.push({
|
||||
code: 'PROJECT_NO_PROGRESS',
|
||||
message: 'review phase: progress.json absent — scope detection will fall back to brief.md mtime',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: artifacts };
|
||||
}
|
||||
165
plugins/voyage/lib/review/plan-review-dedup.mjs
Normal file
165
plugins/voyage/lib/review/plan-review-dedup.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// lib/review/plan-review-dedup.mjs
|
||||
// Phase-9 dedup helper for /trekplan adversarial review:
|
||||
// merges plan-critic + scope-guardian findings into a single deduplicated
|
||||
// stream, preserving provenance (which agent originally raised each finding).
|
||||
//
|
||||
// Two dedup signals:
|
||||
// 1. Exact match — identical computeFindingId(file:line:rule_key) → merge.
|
||||
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
|
||||
//
|
||||
// Provenance is preserved on the surviving finding's `raised_by` array.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/review/plan-review-dedup.mjs \
|
||||
// --plan-critic /tmp/x.json --scope-guardian /tmp/y.json
|
||||
// → stdout: deduped JSON, exit 0 on success.
|
||||
//
|
||||
// Empty / missing inputs are tolerated (single-agent review still works).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
|
||||
export const DEFAULT_THRESHOLD = 0.7;
|
||||
|
||||
/**
|
||||
* Tokenize a finding's text for Jaccard comparison: lowercase, split on
|
||||
* non-word, drop empties. Stable + deterministic.
|
||||
*/
|
||||
export function tokenize(text) {
|
||||
if (typeof text !== 'string' || text.length === 0) return [];
|
||||
return text.toLowerCase().split(/\W+/).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single agent payload into an array of {agent, finding} pairs.
|
||||
* Tolerates missing payload (returns []).
|
||||
*/
|
||||
function normalizeAgentPayload(payload, fallbackAgent) {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const agent = (typeof payload.agent === 'string' && payload.agent.length > 0)
|
||||
? payload.agent
|
||||
: fallbackAgent;
|
||||
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
||||
return findings.map(f => ({ agent, finding: f }));
|
||||
}
|
||||
|
||||
function annotate(finding, agent) {
|
||||
const id = computeFindingId(
|
||||
String(finding.file ?? 'unknown'),
|
||||
finding.line ?? 0,
|
||||
String(finding.rule_key ?? 'unknown'),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
file: finding.file ?? null,
|
||||
line: finding.line ?? null,
|
||||
rule_key: finding.rule_key ?? null,
|
||||
text: typeof finding.text === 'string' ? finding.text : '',
|
||||
raised_by: [agent],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup an arbitrary collection of agent payloads.
|
||||
*
|
||||
* @param {Array<{agent: string, payload: object | null | undefined}>} sources
|
||||
* @param {{ threshold?: number }} [opts]
|
||||
* @returns {{
|
||||
* findings: Array<object>,
|
||||
* dedup_stats: { total_in: number, total_out: number,
|
||||
* exact_id_dups: number, jaccard_dups: number }
|
||||
* }}
|
||||
*/
|
||||
export function dedupFindings(sources, opts = {}) {
|
||||
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
|
||||
|
||||
const incoming = [];
|
||||
for (const s of sources) {
|
||||
for (const pair of normalizeAgentPayload(s.payload, s.agent)) {
|
||||
incoming.push(annotate(pair.finding, pair.agent));
|
||||
}
|
||||
}
|
||||
|
||||
const total_in = incoming.length;
|
||||
|
||||
// Pass 1 — exact id dedup
|
||||
const byId = new Map();
|
||||
let exact_id_dups = 0;
|
||||
for (const f of incoming) {
|
||||
const existing = byId.get(f.id);
|
||||
if (existing) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!existing.raised_by.includes(a)) existing.raised_by.push(a);
|
||||
}
|
||||
exact_id_dups += 1;
|
||||
} else {
|
||||
byId.set(f.id, f);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — jaccard on text tokens; merge near-duplicates
|
||||
const survivors = [];
|
||||
let jaccard_dups = 0;
|
||||
for (const f of byId.values()) {
|
||||
const tokens = tokenize(f.text);
|
||||
let merged = false;
|
||||
for (const s of survivors) {
|
||||
const sim = jaccardSimilarity(tokens, tokenize(s.text));
|
||||
if (meetsThreshold(sim, threshold)) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!s.raised_by.includes(a)) s.raised_by.push(a);
|
||||
}
|
||||
jaccard_dups += 1;
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!merged) survivors.push(f);
|
||||
}
|
||||
|
||||
return {
|
||||
findings: survivors,
|
||||
dedup_stats: {
|
||||
total_in,
|
||||
total_out: survivors.length,
|
||||
exact_id_dups,
|
||||
jaccard_dups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--plan-critic') out.planCritic = argv[++i];
|
||||
else if (a === '--scope-guardian') out.scopeGuardian = argv[++i];
|
||||
else if (a === '--threshold') out.threshold = Number(argv[++i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJsonOrNull(path) {
|
||||
if (!path) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: readJsonOrNull(args.planCritic) },
|
||||
{ agent: 'scope-guardian', payload: readJsonOrNull(args.scopeGuardian) },
|
||||
];
|
||||
const opts = {};
|
||||
if (Number.isFinite(args.threshold)) opts.threshold = args.threshold;
|
||||
const result = dedupFindings(sources, opts);
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
106
plugins/voyage/lib/review/rule-catalogue.mjs
Normal file
106
plugins/voyage/lib/review/rule-catalogue.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// lib/review/rule-catalogue.mjs
|
||||
// Canonical rule catalogue for /trekreview v1.0.
|
||||
//
|
||||
// 12 rule keys, 4-tier severity (matches brief contract).
|
||||
// llm-security 5-tier alignment is a v1.1 candidate.
|
||||
|
||||
export const SEVERITY_VALUES = Object.freeze(['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION']);
|
||||
|
||||
export const CATEGORY_VALUES = Object.freeze([
|
||||
'conformance',
|
||||
'correctness',
|
||||
'scope',
|
||||
'tests',
|
||||
'security',
|
||||
'maintenance',
|
||||
]);
|
||||
|
||||
export const RULE_CATALOGUE = Object.freeze([
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_BRIEF_REF',
|
||||
severity: 'MAJOR',
|
||||
category: 'conformance',
|
||||
description: 'Finding lacks brief_ref pointing to the brief section it traces back to.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'UNIMPLEMENTED_CRITERION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'conformance',
|
||||
description: 'A brief Success Criterion has no corresponding implementation in the delivered code.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'SCOPE_CREEP_BUILT',
|
||||
severity: 'MAJOR',
|
||||
category: 'scope',
|
||||
description: 'Code implements features beyond what the brief requested.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'NON_GOAL_VIOLATED',
|
||||
severity: 'BLOCKER',
|
||||
category: 'scope',
|
||||
description: 'Code implements something the brief explicitly listed as a Non-Goal.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_TEST',
|
||||
severity: 'MAJOR',
|
||||
category: 'tests',
|
||||
description: 'Delivered behavior has no automated test coverage.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'SECURITY_INJECTION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'security',
|
||||
description: 'Code path constructs commands, queries, or templates from untrusted input without sanitization.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'PLACEHOLDER_IN_CODE',
|
||||
severity: 'MAJOR',
|
||||
category: 'maintenance',
|
||||
description: 'Committed code contains TBD/TODO/FIXME/XXX/console.log/debugger placeholders.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_ERROR_HANDLING',
|
||||
severity: 'MINOR',
|
||||
category: 'correctness',
|
||||
description: 'Code path can fail silently (uncaught promise, unchecked return, missing try/catch on I/O).',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'UNDECLARED_DEPENDENCY',
|
||||
severity: 'MAJOR',
|
||||
category: 'maintenance',
|
||||
description: 'Code imports or invokes something not declared in package.json / not bundled / not present in PATH.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'PLAN_EXECUTE_DRIFT',
|
||||
severity: 'MAJOR',
|
||||
category: 'conformance',
|
||||
description: 'Delivered code diverges from what the plan said would be built (different file, different approach, different API).',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'BROKEN_SUCCESS_CRITERION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'conformance',
|
||||
description: 'A brief Success Criterion is implemented but the verification command/test fails or is structurally incorrect.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'COVERAGE_SILENT_SKIP',
|
||||
severity: 'MAJOR',
|
||||
category: 'tests',
|
||||
description: 'Triage gate skipped a file without recording it in the Coverage section of review.md (hidden truncation).',
|
||||
}),
|
||||
]);
|
||||
|
||||
export const RULE_KEYS = Object.freeze(new Set(RULE_CATALOGUE.map((r) => r.rule_key)));
|
||||
|
||||
/**
|
||||
* Look up a rule entry by its key.
|
||||
* @param {string} key
|
||||
* @returns {object|null} the frozen entry, or null if not found
|
||||
*/
|
||||
export function getRule(key) {
|
||||
if (typeof key !== 'string') return null;
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
if (entry.rule_key === key) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
117
plugins/voyage/lib/stats/cache-analyzer.mjs
Normal file
117
plugins/voyage/lib/stats/cache-analyzer.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// lib/stats/cache-analyzer.mjs
|
||||
// Summarizes trekexecute-stats.jsonl: total events, percentile wall times,
|
||||
// time range. Companion to event-emit.mjs (which produces the jsonl).
|
||||
//
|
||||
// Designed for /trekplan Spor C: gives C3 telemetry context when
|
||||
// interpreting Q3 experiment numbers (5+ weeks of accumulated data on the
|
||||
// operator's machine as of 2026-05-04).
|
||||
//
|
||||
// Zero npm dependencies. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
function usage() {
|
||||
return `cache-analyzer.mjs — summarize trekexecute-stats.jsonl
|
||||
|
||||
USAGE:
|
||||
node lib/stats/cache-analyzer.mjs --json <path-to-jsonl>
|
||||
|
||||
OUTPUT (stdout, JSON):
|
||||
{
|
||||
"total_events": <n>,
|
||||
"events_with_duration": <n>,
|
||||
"wall_time_ms_p50": <ms or null>,
|
||||
"wall_time_ms_p90": <ms or null>,
|
||||
"wall_time_ms_max": <ms or null>,
|
||||
"unique_event_names": [...],
|
||||
"oldest_event_iso": "<iso8601 or null>",
|
||||
"newest_event_iso": "<iso8601 or null>"
|
||||
}
|
||||
|
||||
EXIT:
|
||||
0 success, 1 file not found / read error, 2 usage error.
|
||||
`;
|
||||
}
|
||||
|
||||
export function summarize(lines) {
|
||||
const summary = {
|
||||
total_events: 0,
|
||||
events_with_duration: 0,
|
||||
wall_time_ms_p50: null,
|
||||
wall_time_ms_p90: null,
|
||||
wall_time_ms_max: null,
|
||||
unique_event_names: [],
|
||||
oldest_event_iso: null,
|
||||
newest_event_iso: null,
|
||||
};
|
||||
|
||||
const durations = [];
|
||||
const names = new Set();
|
||||
let oldestMs = null;
|
||||
let newestMs = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { continue; }
|
||||
summary.total_events++;
|
||||
if (obj.event && typeof obj.event === 'string') names.add(obj.event);
|
||||
else if (obj.name && typeof obj.name === 'string') names.add(obj.name);
|
||||
if (typeof obj.duration_ms === 'number' && Number.isFinite(obj.duration_ms)) {
|
||||
durations.push(obj.duration_ms);
|
||||
summary.events_with_duration++;
|
||||
}
|
||||
const tsField = obj.timestamp || obj.ts || obj.iso || obj.time;
|
||||
if (typeof tsField === 'string') {
|
||||
const t = Date.parse(tsField);
|
||||
if (!Number.isNaN(t)) {
|
||||
if (oldestMs === null || t < oldestMs) oldestMs = t;
|
||||
if (newestMs === null || t > newestMs) newestMs = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (durations.length > 0) {
|
||||
durations.sort((a, b) => a - b);
|
||||
const p50Idx = Math.floor(durations.length * 0.5);
|
||||
const p90Idx = Math.floor(durations.length * 0.9);
|
||||
summary.wall_time_ms_p50 = durations[Math.min(p50Idx, durations.length - 1)];
|
||||
summary.wall_time_ms_p90 = durations[Math.min(p90Idx, durations.length - 1)];
|
||||
summary.wall_time_ms_max = durations[durations.length - 1];
|
||||
}
|
||||
|
||||
summary.unique_event_names = [...names].sort();
|
||||
if (oldestMs !== null) summary.oldest_event_iso = new Date(oldestMs).toISOString();
|
||||
if (newestMs !== null) summary.newest_event_iso = new Date(newestMs).toISOString();
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function summarizeFile(path) {
|
||||
if (!existsSync(path)) {
|
||||
return { error: `file not found: ${path}` };
|
||||
}
|
||||
let text;
|
||||
try { text = readFileSync(path, 'utf-8'); }
|
||||
catch (e) { return { error: `read error: ${e.message}` }; }
|
||||
return summarize(text.split('\n'));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonIdx = args.indexOf('--json');
|
||||
if (jsonIdx === -1 || !args[jsonIdx + 1]) {
|
||||
process.stderr.write(usage());
|
||||
process.exit(2);
|
||||
}
|
||||
const path = args[jsonIdx + 1];
|
||||
const result = summarizeFile(path);
|
||||
if (result.error) {
|
||||
process.stderr.write(`cache-analyzer: ${result.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
117
plugins/voyage/lib/stats/event-emit.mjs
Normal file
117
plugins/voyage/lib/stats/event-emit.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// lib/stats/event-emit.mjs
|
||||
// Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6).
|
||||
//
|
||||
// Writes one line per event to ${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl
|
||||
// (or override via CLAUDE_PLUGIN_DATA env var; falls back to silent skip if
|
||||
// the directory doesn't exist — stats failures must NEVER block workflow).
|
||||
//
|
||||
// Every emission carries:
|
||||
// - ts : ISO-8601 timestamp (REQUIRED per SC4 contract)
|
||||
// - event : the requested event name
|
||||
// - known_event : true for recognized events, false otherwise
|
||||
// - payload : caller-supplied object (may be {})
|
||||
//
|
||||
// Recognized events: brief-approved, main-merge-gate, user_input.
|
||||
// Unknown event names are still emitted (with known_event: false) so that
|
||||
// the audit trail is complete; downstream consumers filter as needed.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/stats/event-emit.mjs --event brief-approved --payload '{...}'
|
||||
// → exit 0 (always); silent on stat dir absence.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export const KNOWN_EVENTS = Object.freeze(new Set([
|
||||
'brief-approved',
|
||||
'main-merge-gate',
|
||||
'user_input',
|
||||
]));
|
||||
|
||||
const STATS_FILENAME = 'trekexecute-stats.jsonl';
|
||||
|
||||
/**
|
||||
* Resolve the stats file path. Honors CLAUDE_PLUGIN_DATA env var.
|
||||
* Returns null if no plugin-data dir is configured (silent-skip mode).
|
||||
*/
|
||||
export function resolveStatsPath(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dir || typeof dir !== 'string' || dir.length === 0) return null;
|
||||
return join(dir, STATS_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON record. Pure — no I/O.
|
||||
*/
|
||||
export function buildRecord(event, payload = {}, now = new Date()) {
|
||||
if (typeof event !== 'string' || event.length === 0) {
|
||||
throw new TypeError('event must be a non-empty string');
|
||||
}
|
||||
return {
|
||||
ts: now.toISOString(),
|
||||
event,
|
||||
known_event: KNOWN_EVENTS.has(event),
|
||||
payload: (payload && typeof payload === 'object') ? payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event. Never throws — stat failures are swallowed silently
|
||||
* because lifecycle telemetry must not block the user's workflow.
|
||||
*
|
||||
* @returns {{ written: boolean, path: string | null, reason?: string }}
|
||||
*/
|
||||
export function emit(event, payload = {}, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const now = opts.now || new Date();
|
||||
let record;
|
||||
try {
|
||||
record = buildRecord(event, payload, now);
|
||||
} catch (e) {
|
||||
return { written: false, path: null, reason: `record-build: ${e.message}` };
|
||||
}
|
||||
const path = opts.path || resolveStatsPath(env);
|
||||
if (!path) return { written: false, path: null, reason: 'CLAUDE_PLUGIN_DATA unset' };
|
||||
try {
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) {
|
||||
// Best-effort dir creation; if it fails, swallow and skip.
|
||||
try { mkdirSync(dir, { recursive: true }); } catch { return { written: false, path, reason: 'dir-mkdir-failed' }; }
|
||||
}
|
||||
appendFileSync(path, JSON.stringify(record) + '\n');
|
||||
return { written: true, path };
|
||||
} catch (e) {
|
||||
return { written: false, path, reason: `append-failed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--event') out.event = argv[++i];
|
||||
else if (a === '--payload') out.payload = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.event) {
|
||||
process.stdout.write(JSON.stringify({ written: false, reason: 'usage: --event NAME [--payload JSON]' }) + '\n');
|
||||
process.exit(0); // never block: usage error still exits clean
|
||||
}
|
||||
let payload = {};
|
||||
if (args.payload) {
|
||||
try { payload = JSON.parse(args.payload); }
|
||||
catch {
|
||||
process.stdout.write(JSON.stringify({ written: false, reason: 'payload-not-json' }) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
const result = emit(args.event, payload);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
14
plugins/voyage/lib/util/atomic-write.mjs
Normal file
14
plugins/voyage/lib/util/atomic-write.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// lib/util/atomic-write.mjs
|
||||
// Atomic JSON file write — writes to {path}.tmp then renames to {path}.
|
||||
// Crash-safe: a partial write leaves the original file untouched.
|
||||
//
|
||||
// Extracted from hooks/scripts/pre-compact-flush.mjs in v3.3.0 so that
|
||||
// session-state writers and progress.json writers share one implementation.
|
||||
|
||||
import { writeFileSync, renameSync } from 'node:fs';
|
||||
|
||||
export function atomicWriteJson(path, obj) {
|
||||
const tmp = path + '.tmp';
|
||||
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
129
plugins/voyage/lib/util/autonomy-gate.mjs
Normal file
129
plugins/voyage/lib/util/autonomy-gate.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// lib/util/autonomy-gate.mjs
|
||||
// Autonomy-gate state machine for /trekexecute + /trekplan
|
||||
// (plan-v2 Step 4 — drives the --gates flag).
|
||||
//
|
||||
// States:
|
||||
// idle — not yet started
|
||||
// gates_on — gates enabled, between phases
|
||||
// auto_running — running phases continuously without pausing
|
||||
// paused_for_gate — stopped at a phase boundary; awaiting `resume`
|
||||
// completed — terminal
|
||||
//
|
||||
// Events:
|
||||
// start — begin a run (gates flag chooses route)
|
||||
// phase_boundary — a phase finished
|
||||
// resume — operator confirmed; leave the gate
|
||||
// finish — pipeline reached its end
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
|
||||
// → JSON: { ok: true, next_state: "..." } (success)
|
||||
// → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
|
||||
//
|
||||
// Pure data; no I/O. Re-entry to `completed` is idempotent.
|
||||
|
||||
export const STATES = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
GATES_ON: 'gates_on',
|
||||
AUTO_RUNNING: 'auto_running',
|
||||
PAUSED_FOR_GATE: 'paused_for_gate',
|
||||
COMPLETED: 'completed',
|
||||
});
|
||||
|
||||
export const EVENTS = Object.freeze({
|
||||
START: 'start',
|
||||
PHASE_BOUNDARY: 'phase_boundary',
|
||||
RESUME: 'resume',
|
||||
FINISH: 'finish',
|
||||
});
|
||||
|
||||
const STATE_SET = new Set(Object.values(STATES));
|
||||
const EVENT_SET = new Set(Object.values(EVENTS));
|
||||
|
||||
/**
|
||||
* Compute the next state given the current state, event, and (optional)
|
||||
* gates-flag intent (only consulted on `start` from `idle`).
|
||||
*
|
||||
* @param {string} state
|
||||
* @param {string} event
|
||||
* @param {{ gates?: boolean }} [opts]
|
||||
* @returns {{ ok: true, next_state: string } | { ok: false, error: string }}
|
||||
*/
|
||||
export function transition(state, event, opts = {}) {
|
||||
if (!STATE_SET.has(state)) {
|
||||
return { ok: false, error: `unknown state: ${state}` };
|
||||
}
|
||||
if (!EVENT_SET.has(event)) {
|
||||
return { ok: false, error: `unknown event: ${event}` };
|
||||
}
|
||||
|
||||
// completed is terminal & idempotent
|
||||
if (state === STATES.COMPLETED) {
|
||||
return { ok: true, next_state: STATES.COMPLETED };
|
||||
}
|
||||
|
||||
if (state === STATES.IDLE) {
|
||||
if (event === EVENTS.START) {
|
||||
const gates = opts.gates === true;
|
||||
return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING };
|
||||
}
|
||||
return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` };
|
||||
}
|
||||
|
||||
if (state === STATES.GATES_ON) {
|
||||
if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: gates_on + ${event}` };
|
||||
}
|
||||
|
||||
if (state === STATES.AUTO_RUNNING) {
|
||||
if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: auto_running + ${event}` };
|
||||
}
|
||||
|
||||
if (state === STATES.PAUSED_FOR_GATE) {
|
||||
if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: paused_for_gate + ${event}` };
|
||||
}
|
||||
|
||||
return { ok: false, error: `unhandled state: ${state}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: is this state terminal?
|
||||
*/
|
||||
export function isTerminal(state) {
|
||||
return state === STATES.COMPLETED;
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--state') out.state = argv[++i];
|
||||
else if (a === '--event') out.event = argv[++i];
|
||||
else if (a === '--gates') {
|
||||
const v = argv[++i];
|
||||
out.gates = v === 'true';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.state || !args.event) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates true|false]',
|
||||
}) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const result = transition(args.state, args.event, { gates: args.gates });
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
94
plugins/voyage/lib/util/cleanup.mjs
Normal file
94
plugins/voyage/lib/util/cleanup.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// lib/util/cleanup.mjs
|
||||
// Bug 4 — operator-invoked cleanup of completed-project state files.
|
||||
//
|
||||
// The trekplan pipeline does NOT auto-cleanup state on session-end:
|
||||
// stale .session-state.local.json + NEXT-SESSION-PROMPT.local.md across many
|
||||
// projects accumulate over time. This util removes them safely once the
|
||||
// project is fully done (status === 'completed' as seen by validateSessionState).
|
||||
//
|
||||
// Invariants:
|
||||
// - Strict equality on parsed.status === 'completed' (no soft-match).
|
||||
// - Idempotent: re-running on a partially-cleaned dir succeeds with deleted: [].
|
||||
// - Refuses dryRun: false without an explicit confirm: true (prevents accidents).
|
||||
// - ENOENT counts as "already absent" — never an error.
|
||||
// - Cleanup is operator-invoked from /trekcontinue --cleanup; no Bash binding here.
|
||||
|
||||
import { existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { issue, fail, ok } from './result.mjs';
|
||||
import { validateSessionState } from '../validators/session-state-validator.mjs';
|
||||
|
||||
const CANDIDATE_FILES = Object.freeze([
|
||||
'.session-state.local.json',
|
||||
'NEXT-SESSION-PROMPT.local.md',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Clean up state files for a completed trekplan project.
|
||||
*
|
||||
* @param {string} projectDir - absolute or cwd-relative path to the project directory
|
||||
* @param {{dryRun?: boolean, confirm?: boolean}} [opts]
|
||||
* @returns {{valid: boolean, errors: object[], warnings: object[], parsed?: {wouldDelete?: string[], deleted?: string[]}}}
|
||||
*/
|
||||
export function cleanupProject(projectDir, opts = {}) {
|
||||
const dryRun = opts.dryRun !== false; // default true
|
||||
const confirm = opts.confirm === true;
|
||||
|
||||
if (!dryRun && !confirm) {
|
||||
return fail(issue(
|
||||
'CLEANUP_REQUIRES_CONFIRM',
|
||||
'Refused: dryRun=false requires confirm=true (explicit operator confirmation)',
|
||||
'Re-run with {dryRun: false, confirm: true} to actually delete files.',
|
||||
));
|
||||
}
|
||||
|
||||
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
||||
return fail(issue('CLEANUP_INVALID_PROJECT_DIR', 'projectDir must be a non-empty string'));
|
||||
}
|
||||
|
||||
const stateFile = join(projectDir, '.session-state.local.json');
|
||||
|
||||
if (!existsSync(stateFile)) {
|
||||
return fail(issue(
|
||||
'CLEANUP_NO_STATE_FILE',
|
||||
`No state file at ${stateFile}; nothing to clean up`,
|
||||
'cleanup is only valid for projects that have a .session-state.local.json with status: completed',
|
||||
));
|
||||
}
|
||||
|
||||
const validation = validateSessionState(stateFile);
|
||||
if (!validation.valid) {
|
||||
return fail(issue(
|
||||
'CLEANUP_INVALID_STATE_FILE',
|
||||
`State file at ${stateFile} is invalid: ${validation.errors.map(e => e.code).join(', ')}`,
|
||||
));
|
||||
}
|
||||
|
||||
if (validation.parsed.status !== 'completed') {
|
||||
return fail(issue(
|
||||
'CLEANUP_NOT_COMPLETED',
|
||||
`Refused: status is "${validation.parsed.status}", not "completed"`,
|
||||
'cleanup is reserved for fully-finished projects. Resume via /trekcontinue or wait until the run completes.',
|
||||
));
|
||||
}
|
||||
|
||||
const candidates = CANDIDATE_FILES.map(f => join(projectDir, f));
|
||||
|
||||
if (dryRun) {
|
||||
const wouldDelete = candidates.filter(p => existsSync(p));
|
||||
return { valid: true, errors: [], warnings: [], parsed: { wouldDelete, deleted: [] } };
|
||||
}
|
||||
|
||||
const deleted = [];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
unlinkSync(p);
|
||||
deleted.push(p);
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') continue; // idempotent: already absent
|
||||
return fail(issue('CLEANUP_UNLINK_FAILED', `Failed to delete ${p}: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ wouldDelete: [], deleted });
|
||||
}
|
||||
158
plugins/voyage/lib/util/frontmatter.mjs
Normal file
158
plugins/voyage/lib/util/frontmatter.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// lib/util/frontmatter.mjs
|
||||
// Hand-rolled YAML-frontmatter parser.
|
||||
//
|
||||
// Supported subset:
|
||||
// - String scalars (quoted or unquoted)
|
||||
// - Numbers (integer + float)
|
||||
// - Booleans (true / false)
|
||||
// - null
|
||||
// - Single-level dicts
|
||||
// - Lists of scalars (- value)
|
||||
//
|
||||
// Deliberately rejects: nested dicts in lists, multi-line strings,
|
||||
// anchors/aliases, tags, flow style ({...} / [...]).
|
||||
//
|
||||
// Why no js-yaml: zero-deps invariant. Templates emit only this subset.
|
||||
|
||||
import { issue, ok, fail } from './result.mjs';
|
||||
|
||||
const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/;
|
||||
|
||||
/**
|
||||
* Split raw markdown into { frontmatter, body }.
|
||||
* Returns { hasFrontmatter: false } when no leading --- block exists.
|
||||
*/
|
||||
export function splitFrontmatter(text) {
|
||||
if (typeof text !== 'string') return { hasFrontmatter: false, body: '' };
|
||||
const stripped = text.replace(/^/, '');
|
||||
const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||
if (!m) return { hasFrontmatter: false, body: stripped };
|
||||
return {
|
||||
hasFrontmatter: true,
|
||||
frontmatter: m[1],
|
||||
body: m[2] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a YAML-frontmatter string into a JS object.
|
||||
* @returns {import('./result.mjs').Result}
|
||||
*/
|
||||
export function parseFrontmatter(yamlText) {
|
||||
if (typeof yamlText !== 'string') {
|
||||
return fail(issue('FM_INPUT', 'Frontmatter input is not a string'));
|
||||
}
|
||||
const lines = yamlText.split(/\r?\n/);
|
||||
const out = {};
|
||||
const errors = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim() === '' || line.trimStart().startsWith('#')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[0].length : 0;
|
||||
if (indent > 0) {
|
||||
errors.push(issue('FM_INDENT', `Unexpected indentation at line ${i + 1}`, 'Top-level keys only; nested dicts unsupported.'));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
||||
if (!kv) {
|
||||
errors.push(issue('FM_SYNTAX', `Cannot parse line ${i + 1}: ${line}`));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = kv[1];
|
||||
const rest = kv[2];
|
||||
|
||||
if (rest === '' || rest === undefined) {
|
||||
const list = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const next = lines[j];
|
||||
if (next.trim() === '') { j++; continue; }
|
||||
const itemMatch = next.match(/^(\s+)-\s+(.*)$/);
|
||||
if (!itemMatch) break;
|
||||
const itemIndent = itemMatch[1].length;
|
||||
const firstContent = itemMatch[2];
|
||||
const dictKeyMatch = firstContent.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
||||
if (dictKeyMatch) {
|
||||
const item = {};
|
||||
item[dictKeyMatch[1]] = parseScalar(dictKeyMatch[2]);
|
||||
let k = j + 1;
|
||||
while (k < lines.length) {
|
||||
const cont = lines[k];
|
||||
if (cont.trim() === '') { k++; continue; }
|
||||
const contMatch = cont.match(/^(\s+)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
||||
if (!contMatch) break;
|
||||
if (contMatch[1].length <= itemIndent + 1) break;
|
||||
item[contMatch[2]] = parseScalar(contMatch[3]);
|
||||
k++;
|
||||
}
|
||||
list.push(item);
|
||||
j = k;
|
||||
} else {
|
||||
list.push(parseScalar(firstContent));
|
||||
j++;
|
||||
}
|
||||
}
|
||||
if (list.length > 0) {
|
||||
out[key] = list;
|
||||
i = j;
|
||||
} else {
|
||||
out[key] = null;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = parseScalar(rest);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { valid: false, errors, warnings: [], parsed: out };
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if (s === '') return '';
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === '[]') return [];
|
||||
if (s === '{}') return {};
|
||||
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
|
||||
if (/^-?\d+\.\d+$/.test(s)) return Number.parseFloat(s);
|
||||
if (s.startsWith('"') && s.endsWith('"')) {
|
||||
return s.slice(1, -1).replace(/\\(.)/g, (_, ch) => {
|
||||
if (ch === 'n') return '\n';
|
||||
if (ch === 't') return '\t';
|
||||
if (ch === 'r') return '\r';
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file's frontmatter directly from its full text.
|
||||
* @returns {import('./result.mjs').Result}
|
||||
*/
|
||||
export function parseDocument(text) {
|
||||
const split = splitFrontmatter(text);
|
||||
if (!split.hasFrontmatter) {
|
||||
return fail(issue('FM_MISSING', 'No frontmatter block found'));
|
||||
}
|
||||
const result = parseFrontmatter(split.frontmatter);
|
||||
return { ...result, parsed: { frontmatter: result.parsed, body: split.body } };
|
||||
}
|
||||
35
plugins/voyage/lib/util/result.mjs
Normal file
35
plugins/voyage/lib/util/result.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// lib/util/result.mjs
|
||||
// Validation result shape used by every validator and parser.
|
||||
|
||||
/**
|
||||
* @typedef {{ code: string, message: string, hint?: string, location?: string }} Issue
|
||||
* @typedef {{ valid: boolean, errors: Issue[], warnings: Issue[], parsed?: any }} Result
|
||||
*/
|
||||
|
||||
/** @returns {Result} */
|
||||
export function ok(parsed) {
|
||||
return { valid: true, errors: [], warnings: [], parsed };
|
||||
}
|
||||
|
||||
/** @returns {Result} */
|
||||
export function fail(errors, parsed) {
|
||||
return { valid: false, errors: Array.isArray(errors) ? errors : [errors], warnings: [], parsed };
|
||||
}
|
||||
|
||||
/** @returns {Result} */
|
||||
export function combine(results) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
let parsed;
|
||||
for (const r of results) {
|
||||
if (r.errors) errors.push(...r.errors);
|
||||
if (r.warnings) warnings.push(...r.warnings);
|
||||
if (r.parsed !== undefined && parsed === undefined) parsed = r.parsed;
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
||||
/** @returns {Issue} */
|
||||
export function issue(code, message, hint, location) {
|
||||
return { code, message, hint, location };
|
||||
}
|
||||
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