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

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

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

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

View file

@ -0,0 +1,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 };

View 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));
}

View 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) };
}

View 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;
}

View 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 };
}

View 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 } };
}

View 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 };
}

View 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);
}

View 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;
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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 });
}

View 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 } };
}

View 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 };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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