feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope. - git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved) - .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local - CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list - README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph - plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path - plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed) Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
This commit is contained in:
parent
8f1bf9b7b4
commit
7a90d348ad
149 changed files with 26 additions and 33 deletions
165
plugins/voyage/lib/review/plan-review-dedup.mjs
Normal file
165
plugins/voyage/lib/review/plan-review-dedup.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// lib/review/plan-review-dedup.mjs
|
||||
// Phase-9 dedup helper for /trekplan adversarial review:
|
||||
// merges plan-critic + scope-guardian findings into a single deduplicated
|
||||
// stream, preserving provenance (which agent originally raised each finding).
|
||||
//
|
||||
// Two dedup signals:
|
||||
// 1. Exact match — identical computeFindingId(file:line:rule_key) → merge.
|
||||
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
|
||||
//
|
||||
// Provenance is preserved on the surviving finding's `raised_by` array.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/review/plan-review-dedup.mjs \
|
||||
// --plan-critic /tmp/x.json --scope-guardian /tmp/y.json
|
||||
// → stdout: deduped JSON, exit 0 on success.
|
||||
//
|
||||
// Empty / missing inputs are tolerated (single-agent review still works).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
|
||||
export const DEFAULT_THRESHOLD = 0.7;
|
||||
|
||||
/**
|
||||
* Tokenize a finding's text for Jaccard comparison: lowercase, split on
|
||||
* non-word, drop empties. Stable + deterministic.
|
||||
*/
|
||||
export function tokenize(text) {
|
||||
if (typeof text !== 'string' || text.length === 0) return [];
|
||||
return text.toLowerCase().split(/\W+/).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single agent payload into an array of {agent, finding} pairs.
|
||||
* Tolerates missing payload (returns []).
|
||||
*/
|
||||
function normalizeAgentPayload(payload, fallbackAgent) {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const agent = (typeof payload.agent === 'string' && payload.agent.length > 0)
|
||||
? payload.agent
|
||||
: fallbackAgent;
|
||||
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
||||
return findings.map(f => ({ agent, finding: f }));
|
||||
}
|
||||
|
||||
function annotate(finding, agent) {
|
||||
const id = computeFindingId(
|
||||
String(finding.file ?? 'unknown'),
|
||||
finding.line ?? 0,
|
||||
String(finding.rule_key ?? 'unknown'),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
file: finding.file ?? null,
|
||||
line: finding.line ?? null,
|
||||
rule_key: finding.rule_key ?? null,
|
||||
text: typeof finding.text === 'string' ? finding.text : '',
|
||||
raised_by: [agent],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup an arbitrary collection of agent payloads.
|
||||
*
|
||||
* @param {Array<{agent: string, payload: object | null | undefined}>} sources
|
||||
* @param {{ threshold?: number }} [opts]
|
||||
* @returns {{
|
||||
* findings: Array<object>,
|
||||
* dedup_stats: { total_in: number, total_out: number,
|
||||
* exact_id_dups: number, jaccard_dups: number }
|
||||
* }}
|
||||
*/
|
||||
export function dedupFindings(sources, opts = {}) {
|
||||
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
|
||||
|
||||
const incoming = [];
|
||||
for (const s of sources) {
|
||||
for (const pair of normalizeAgentPayload(s.payload, s.agent)) {
|
||||
incoming.push(annotate(pair.finding, pair.agent));
|
||||
}
|
||||
}
|
||||
|
||||
const total_in = incoming.length;
|
||||
|
||||
// Pass 1 — exact id dedup
|
||||
const byId = new Map();
|
||||
let exact_id_dups = 0;
|
||||
for (const f of incoming) {
|
||||
const existing = byId.get(f.id);
|
||||
if (existing) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!existing.raised_by.includes(a)) existing.raised_by.push(a);
|
||||
}
|
||||
exact_id_dups += 1;
|
||||
} else {
|
||||
byId.set(f.id, f);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — jaccard on text tokens; merge near-duplicates
|
||||
const survivors = [];
|
||||
let jaccard_dups = 0;
|
||||
for (const f of byId.values()) {
|
||||
const tokens = tokenize(f.text);
|
||||
let merged = false;
|
||||
for (const s of survivors) {
|
||||
const sim = jaccardSimilarity(tokens, tokenize(s.text));
|
||||
if (meetsThreshold(sim, threshold)) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!s.raised_by.includes(a)) s.raised_by.push(a);
|
||||
}
|
||||
jaccard_dups += 1;
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!merged) survivors.push(f);
|
||||
}
|
||||
|
||||
return {
|
||||
findings: survivors,
|
||||
dedup_stats: {
|
||||
total_in,
|
||||
total_out: survivors.length,
|
||||
exact_id_dups,
|
||||
jaccard_dups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--plan-critic') out.planCritic = argv[++i];
|
||||
else if (a === '--scope-guardian') out.scopeGuardian = argv[++i];
|
||||
else if (a === '--threshold') out.threshold = Number(argv[++i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJsonOrNull(path) {
|
||||
if (!path) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: readJsonOrNull(args.planCritic) },
|
||||
{ agent: 'scope-guardian', payload: readJsonOrNull(args.scopeGuardian) },
|
||||
];
|
||||
const opts = {};
|
||||
if (Number.isFinite(args.threshold)) opts.threshold = args.threshold;
|
||||
const result = dedupFindings(sources, opts);
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
106
plugins/voyage/lib/review/rule-catalogue.mjs
Normal file
106
plugins/voyage/lib/review/rule-catalogue.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// lib/review/rule-catalogue.mjs
|
||||
// Canonical rule catalogue for /trekreview v1.0.
|
||||
//
|
||||
// 12 rule keys, 4-tier severity (matches brief contract).
|
||||
// llm-security 5-tier alignment is a v1.1 candidate.
|
||||
|
||||
export const SEVERITY_VALUES = Object.freeze(['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION']);
|
||||
|
||||
export const CATEGORY_VALUES = Object.freeze([
|
||||
'conformance',
|
||||
'correctness',
|
||||
'scope',
|
||||
'tests',
|
||||
'security',
|
||||
'maintenance',
|
||||
]);
|
||||
|
||||
export const RULE_CATALOGUE = Object.freeze([
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_BRIEF_REF',
|
||||
severity: 'MAJOR',
|
||||
category: 'conformance',
|
||||
description: 'Finding lacks brief_ref pointing to the brief section it traces back to.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'UNIMPLEMENTED_CRITERION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'conformance',
|
||||
description: 'A brief Success Criterion has no corresponding implementation in the delivered code.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'SCOPE_CREEP_BUILT',
|
||||
severity: 'MAJOR',
|
||||
category: 'scope',
|
||||
description: 'Code implements features beyond what the brief requested.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'NON_GOAL_VIOLATED',
|
||||
severity: 'BLOCKER',
|
||||
category: 'scope',
|
||||
description: 'Code implements something the brief explicitly listed as a Non-Goal.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_TEST',
|
||||
severity: 'MAJOR',
|
||||
category: 'tests',
|
||||
description: 'Delivered behavior has no automated test coverage.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'SECURITY_INJECTION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'security',
|
||||
description: 'Code path constructs commands, queries, or templates from untrusted input without sanitization.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'PLACEHOLDER_IN_CODE',
|
||||
severity: 'MAJOR',
|
||||
category: 'maintenance',
|
||||
description: 'Committed code contains TBD/TODO/FIXME/XXX/console.log/debugger placeholders.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'MISSING_ERROR_HANDLING',
|
||||
severity: 'MINOR',
|
||||
category: 'correctness',
|
||||
description: 'Code path can fail silently (uncaught promise, unchecked return, missing try/catch on I/O).',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'UNDECLARED_DEPENDENCY',
|
||||
severity: 'MAJOR',
|
||||
category: 'maintenance',
|
||||
description: 'Code imports or invokes something not declared in package.json / not bundled / not present in PATH.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'PLAN_EXECUTE_DRIFT',
|
||||
severity: 'MAJOR',
|
||||
category: 'conformance',
|
||||
description: 'Delivered code diverges from what the plan said would be built (different file, different approach, different API).',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'BROKEN_SUCCESS_CRITERION',
|
||||
severity: 'BLOCKER',
|
||||
category: 'conformance',
|
||||
description: 'A brief Success Criterion is implemented but the verification command/test fails or is structurally incorrect.',
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'COVERAGE_SILENT_SKIP',
|
||||
severity: 'MAJOR',
|
||||
category: 'tests',
|
||||
description: 'Triage gate skipped a file without recording it in the Coverage section of review.md (hidden truncation).',
|
||||
}),
|
||||
]);
|
||||
|
||||
export const RULE_KEYS = Object.freeze(new Set(RULE_CATALOGUE.map((r) => r.rule_key)));
|
||||
|
||||
/**
|
||||
* Look up a rule entry by its key.
|
||||
* @param {string} key
|
||||
* @returns {object|null} the frozen entry, or null if not found
|
||||
*/
|
||||
export function getRule(key) {
|
||||
if (typeof key !== 'string') return null;
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
if (entry.rule_key === key) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue