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