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
58
plugins/voyage/hooks/scripts/post-bash-stats.mjs
Executable file
58
plugins/voyage/hooks/scripts/post-bash-stats.mjs
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env node
|
||||
// post-bash-stats.mjs — PostToolUse hook (CC v2.1.97+)
|
||||
//
|
||||
// Captures duration_ms from PostToolUse payload for Bash tool calls and
|
||||
// appends a structured stats line to ${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl
|
||||
// when the running session is an trekexecute session.
|
||||
//
|
||||
// Detection: only fires when the tool input matches the verify/checkpoint
|
||||
// pattern of an trekexecute step (i.e., the command was issued from inside
|
||||
// /trekexecute). We err on the side of "log everything in plugin
|
||||
// scope" — duration data is cheap and the alternative is missing real
|
||||
// per-step timings.
|
||||
//
|
||||
// Fail-open invariant: any error → exit 0, no output, no log line.
|
||||
|
||||
import { stdin } from 'node:process';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
async function readStdin() {
|
||||
let data = '';
|
||||
for await (const chunk of stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) return;
|
||||
const payload = JSON.parse(raw);
|
||||
|
||||
if (payload.tool_name !== 'Bash') return;
|
||||
const duration = payload.duration_ms;
|
||||
if (typeof duration !== 'number') return;
|
||||
|
||||
const dataDir = process.env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dataDir) return;
|
||||
|
||||
const cmd = payload.tool_input?.command || '';
|
||||
if (!cmd) return;
|
||||
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: payload.session_id || null,
|
||||
command_excerpt: cmd.slice(0, 120),
|
||||
duration_ms: duration,
|
||||
success: payload.tool_response?.success !== false,
|
||||
});
|
||||
|
||||
const target = join(dataDir, 'trekexecute-stats.jsonl');
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
} catch {}
|
||||
appendFileSync(target, line + '\n');
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
})();
|
||||
74
plugins/voyage/hooks/scripts/post-compact-flush.mjs
Executable file
74
plugins/voyage/hooks/scripts/post-compact-flush.mjs
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: post-compact-flush.mjs
|
||||
// Event: PostCompact (Claude Code v2.1.105+)
|
||||
// Purpose: Re-inject .session-state.local.json after compaction so
|
||||
// /trekcontinue and `/trekexecute --resume` see fresh
|
||||
// session-state and the model has Handover 7 context immediately
|
||||
// after a context-compaction event.
|
||||
//
|
||||
// Read-only — never writes. Always exits 0; never blocks compaction.
|
||||
//
|
||||
// Behavior:
|
||||
// 1. Auto-discover the most-recently-modified
|
||||
// <cwd>/.claude/projects/*/.session-state.local.json
|
||||
// 2. Validate it via lib/validators/session-state-validator.mjs
|
||||
// 3. Emit additionalContext containing project + next_session_label +
|
||||
// status so the next assistant turn has resume context loaded.
|
||||
//
|
||||
// Notes:
|
||||
// - Uses only node:fs sync APIs that have existed since Node 12 (no
|
||||
// glob dependency — that requires Node 22).
|
||||
// - Silent no-op if no state file is discoverable, or if the file is
|
||||
// malformed. Compaction must not be blocked under any circumstance.
|
||||
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { validateSessionState } from '../../lib/validators/session-state-validator.mjs';
|
||||
|
||||
function findActiveStateFile() {
|
||||
// Auto-discover: most recently modified .session-state.local.json
|
||||
// under <cwd>/.claude/projects/*/. Returns absolute path or null.
|
||||
const projectsDir = '.claude/projects';
|
||||
let entries;
|
||||
try { entries = readdirSync(projectsDir, { withFileTypes: true }); }
|
||||
catch { return null; } // .claude/projects/ absent → silent no-op
|
||||
let best = null;
|
||||
let bestMtime = 0;
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const candidate = join(projectsDir, ent.name, '.session-state.local.json');
|
||||
let st;
|
||||
try { st = statSync(candidate); }
|
||||
catch { continue; } // file missing in this project — skip
|
||||
if (st.mtimeMs > bestMtime) {
|
||||
bestMtime = st.mtimeMs;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stateFile = findActiveStateFile();
|
||||
if (!stateFile) {
|
||||
process.stdout.write(JSON.stringify({})); // silent no-op
|
||||
return;
|
||||
}
|
||||
const result = validateSessionState(stateFile);
|
||||
if (!result.valid || !result.parsed) {
|
||||
process.stdout.write(JSON.stringify({})); // silent fail
|
||||
return;
|
||||
}
|
||||
const p = result.parsed;
|
||||
const summary = `[Session resumed after compact]
|
||||
project: ${p.project}
|
||||
next_session: ${p.next_session_label}
|
||||
status: ${p.status}`;
|
||||
process.stdout.write(JSON.stringify({
|
||||
additionalContext: summary.slice(0, 10000),
|
||||
}));
|
||||
}
|
||||
|
||||
try { main(); }
|
||||
catch { process.stdout.write(JSON.stringify({})); } // never block compaction
|
||||
process.exit(0);
|
||||
247
plugins/voyage/hooks/scripts/pre-bash-executor.mjs
Normal file
247
plugins/voyage/hooks/scripts/pre-bash-executor.mjs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-bash-executor.mjs
|
||||
// Event: PreToolUse (Bash)
|
||||
// Purpose: Block or warn about destructive shell commands during plan execution.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
// - tool_input.command — the shell command string
|
||||
// - BLOCK (exit 2): catastrophic/irreversible operations
|
||||
// - WARN (exit 0): risky but recoverable operations — advisory to stderr
|
||||
// - Allow (exit 0): everything else
|
||||
//
|
||||
// Based on llm-security's pre-bash-destructive.mjs with executor-specific additions.
|
||||
// bash-normalize logic copied inline (MIT) — cannot import from separate plugin.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bash normalization (from llm-security/scanners/lib/bash-normalize.mjs)
|
||||
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeBashExpansion(cmd) {
|
||||
if (!cmd || typeof cmd !== 'string') return cmd || '';
|
||||
|
||||
let result = cmd
|
||||
// Strip empty single quotes: w''get -> wget
|
||||
.replace(/''/g, '')
|
||||
// Strip empty double quotes: r""m -> rm
|
||||
.replace(/""/g, '')
|
||||
// Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x)
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
// Multi-char ${ANYTHING} -> '' (unknown value, strip entirely)
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
// Strip backtick subshell with empty/whitespace content
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
// Iteratively strip backslash between word chars (c\u\r\l needs 2 passes)
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLOCK rules — exit 2, command is not executed.
|
||||
// ---------------------------------------------------------------------------
|
||||
const BLOCK_RULES = [
|
||||
{
|
||||
name: 'Filesystem root/home destruction (rm -rf /)',
|
||||
// Matches rm with both -r and -f flags targeting /, ~, or $HOME.
|
||||
// Uses (?:\s|$) instead of \b because / and ~ are non-word chars.
|
||||
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)(?:\s|$)/,
|
||||
description:
|
||||
'`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the filesystem ' +
|
||||
'or home directory. Unconditionally blocked.',
|
||||
},
|
||||
{
|
||||
name: 'World-writable chmod (chmod 777)',
|
||||
pattern: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/,
|
||||
description:
|
||||
'`chmod 777` grants full read/write/execute to all users. ' +
|
||||
'Use minimal permissions (e.g. 644, 755).',
|
||||
},
|
||||
{
|
||||
name: 'Pipe-to-shell (curl|bash, wget|sh)',
|
||||
pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/,
|
||||
description:
|
||||
'Piping remote content into a shell allows arbitrary remote code execution. ' +
|
||||
'Download first, review, then execute.',
|
||||
},
|
||||
{
|
||||
name: 'Fork bomb',
|
||||
pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/,
|
||||
description: 'Fork bomb — exhausts system process resources. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Filesystem format (mkfs)',
|
||||
pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/,
|
||||
description: '`mkfs` formats a filesystem, destroying all data. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Raw disk overwrite via dd',
|
||||
pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
|
||||
description: '`dd` writing to a raw block device destroys disk data. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Direct device write (> /dev/sd*)',
|
||||
pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
|
||||
description: 'Shell redirection to a block device destroys disk data. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'eval with variable/command expansion',
|
||||
pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/,
|
||||
description:
|
||||
'`eval` with variable or command substitution is a code injection vector. ' +
|
||||
'Refactor to use explicit commands.',
|
||||
},
|
||||
// --- Executor-specific additions ---
|
||||
{
|
||||
name: 'System shutdown/reboot',
|
||||
pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/,
|
||||
description: 'System shutdown/reboot commands are blocked during execution.',
|
||||
},
|
||||
{
|
||||
name: 'Cron persistence',
|
||||
pattern: /\bcrontab\b|>\s*\/etc\/cron/,
|
||||
description:
|
||||
'Writing to crontab or /etc/cron* creates persistent scheduled tasks. ' +
|
||||
'Blocked during execution.',
|
||||
},
|
||||
{
|
||||
name: 'Base64-encoded execution',
|
||||
pattern: /\bbase64\b[^|]*\|\s*(?:bash|sh|zsh)\b/,
|
||||
description: 'Base64-decoded content piped to shell is obfuscated code execution. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Kill all processes (kill -9 -1)',
|
||||
pattern: /\b(?:kill|pkill)\s+-9\s+-1\b/,
|
||||
description: 'Killing all user processes with signal 9. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'History destruction',
|
||||
pattern: /\bhistory\s+-c\b|>\s*~\/\.bash_history\b|>\s*~\/\.zsh_history\b/,
|
||||
description: 'Clearing shell history or truncating history files. Blocked.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WARN rules — exit 0 with advisory message on stderr.
|
||||
// ---------------------------------------------------------------------------
|
||||
const WARN_RULES = [
|
||||
{
|
||||
name: 'Force push (git push --force)',
|
||||
pattern: /\bgit\s+push\b[^|&;]*(?:--force|-f)\b/,
|
||||
description:
|
||||
'WARNING: `git push --force` rewrites remote history. Prefer `--force-with-lease`.',
|
||||
},
|
||||
{
|
||||
name: 'Hard reset (git reset --hard)',
|
||||
pattern: /\bgit\s+reset\s+--hard\b/,
|
||||
description:
|
||||
'WARNING: `git reset --hard` permanently discards uncommitted changes.',
|
||||
},
|
||||
{
|
||||
name: 'Recursive remove (rm -rf, non-root)',
|
||||
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+/,
|
||||
description:
|
||||
'WARNING: `rm -rf` permanently deletes files. Verify the target path.',
|
||||
},
|
||||
{
|
||||
name: 'Docker system prune',
|
||||
pattern: /\bdocker\s+system\s+prune\b/,
|
||||
description:
|
||||
'WARNING: `docker system prune` removes all stopped containers and unused images.',
|
||||
},
|
||||
{
|
||||
name: 'npm publish',
|
||||
pattern: /\bnpm\s+publish\b/,
|
||||
description:
|
||||
'WARNING: `npm publish` releases a package to the public registry.',
|
||||
},
|
||||
{
|
||||
name: 'DROP TABLE or DROP DATABASE (SQL)',
|
||||
pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i,
|
||||
description:
|
||||
'WARNING: SQL DROP permanently deletes database objects.',
|
||||
},
|
||||
{
|
||||
name: 'DELETE without WHERE (SQL)',
|
||||
pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i,
|
||||
description:
|
||||
'WARNING: DELETE FROM without WHERE deletes all rows.',
|
||||
},
|
||||
// --- Executor-specific additions ---
|
||||
{
|
||||
name: 'Dependency installation during execution',
|
||||
pattern: /\b(?:npm\s+install\s+--save|pip3?\s+install\s+(?!-e\s+\.)|cargo\s+add)\b/,
|
||||
description:
|
||||
'WARNING: Installing dependencies during plan execution is unusual. ' +
|
||||
'Verify this is intentional.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize: strip ANSI, collapse whitespace
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeCommand(cmd) {
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
let input;
|
||||
try {
|
||||
const raw = readFileSync(0, 'utf-8');
|
||||
input = JSON.parse(raw);
|
||||
} catch {
|
||||
// Cannot parse stdin — fail open.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = input?.tool_input?.command;
|
||||
|
||||
if (!command || typeof command !== 'string') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Strip bash evasion, then normalize whitespace
|
||||
const deobfuscated = normalizeBashExpansion(command);
|
||||
const normalized = normalizeCommand(deobfuscated);
|
||||
|
||||
// Check BLOCK rules first
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.pattern.test(normalized)) {
|
||||
process.stderr.write(
|
||||
`[voyage] BLOCKED: ${rule.name}\n` +
|
||||
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
||||
` ${rule.description}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Check WARN rules (advisory — still exit 0)
|
||||
const warnings = [];
|
||||
for (const rule of WARN_RULES) {
|
||||
if (rule.pattern.test(normalized)) {
|
||||
warnings.push(` [WARN] ${rule.name}: ${rule.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
process.stderr.write(
|
||||
`[voyage] SECURITY ADVISORY: Potentially risky command.\n` +
|
||||
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
||||
warnings.join('\n') + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
186
plugins/voyage/hooks/scripts/pre-compact-flush.mjs
Normal file
186
plugins/voyage/hooks/scripts/pre-compact-flush.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-compact-flush.mjs
|
||||
// Event: PreCompact (Claude Code v2.1.105+)
|
||||
// Purpose: Flush progress.json drift before context compaction so
|
||||
// /trekexecute --resume works after long conversations.
|
||||
// Direct fix for the documented P0 in
|
||||
// docs/trekexecute-v2-observations-from-config-audit-v4.md.
|
||||
//
|
||||
// v3.3.0: also refreshes sibling .session-state.local.json
|
||||
// (Handover 7) so /trekcontinue can detect a resumable session
|
||||
// even after a compaction event mid-run.
|
||||
//
|
||||
// Behavior:
|
||||
// 1. Locate {cwd}/.claude/projects/* / progress.json (any nested project)
|
||||
// 2. Read progress.json + sibling plan.md
|
||||
// 3. Run `git log --oneline {session_start_sha}..HEAD`
|
||||
// 4. For each commit, match against plan steps' commit_message_pattern
|
||||
// 5. If derived current_step > stored current_step → write fresh checkpoint
|
||||
// atomically (tmp + rename), monotonic only (current_step never decreases).
|
||||
// 6. Refresh sibling .session-state.local.json if present and status is
|
||||
// resumable (in_progress | partial) — bumps updated_at only. Never
|
||||
// creates the state file; creation is the writer's job at session-end.
|
||||
// Skips if status is completed/failed/stopped (non-resumable or terminal).
|
||||
// 7. Always exit 0 — NEVER blocks compaction.
|
||||
//
|
||||
// v3.3.0:
|
||||
// - atomicWrite extracted to lib/util/atomic-write.mjs for reuse
|
||||
// - File reformatted (removed pre-existing leading-whitespace syntax error
|
||||
// that silently broke the hook since v3.1.0; PreCompact swallowed it)
|
||||
// - Added Handover 7 sibling-state refresh
|
||||
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { atomicWriteJson } from '../../lib/util/atomic-write.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(HERE, '..', '..');
|
||||
|
||||
function findProgressFiles(cwd) {
|
||||
const projectsDir = join(cwd, '.claude', 'projects');
|
||||
if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return [];
|
||||
const out = [];
|
||||
for (const entry of readdirSync(projectsDir)) {
|
||||
const projDir = join(projectsDir, entry);
|
||||
if (!statSync(projDir).isDirectory()) continue;
|
||||
const progPath = join(projDir, 'progress.json');
|
||||
if (existsSync(progPath) && statSync(progPath).isFile()) {
|
||||
out.push({ projDir, progPath, planPath: join(projDir, 'plan.md') });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try { return JSON.parse(readFileSync(path, 'utf-8')); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function readPlanCheckpointPatterns(planPath) {
|
||||
if (!existsSync(planPath)) return new Map();
|
||||
const text = readFileSync(planPath, 'utf-8');
|
||||
const map = new Map();
|
||||
const stepRe = /^### Step (\d+):/gm;
|
||||
const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/;
|
||||
const headings = [];
|
||||
let m;
|
||||
while ((m = stepRe.exec(text)) !== null) {
|
||||
headings.push({ n: Number.parseInt(m[1], 10), idx: m.index });
|
||||
}
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const start = headings[i].idx;
|
||||
const end = i + 1 < headings.length ? headings[i + 1].idx : text.length;
|
||||
const body = text.slice(start, end);
|
||||
const cp = body.match(checkpointRe);
|
||||
if (cp) {
|
||||
const msg = cp[1];
|
||||
const conventionalPrefix = (msg.match(/^([a-z]+)\(([^)]+)\):/) || [])[0];
|
||||
if (conventionalPrefix) map.set(headings[i].n, conventionalPrefix);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function gitLog(repoDir, baseSha) {
|
||||
if (!baseSha) return [];
|
||||
try {
|
||||
const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD 2>/dev/null`, {
|
||||
encoding: 'utf-8', timeout: 5000,
|
||||
});
|
||||
return out.trim().split('\n').filter(Boolean).map(line => {
|
||||
const sp = line.indexOf(' ');
|
||||
return { sha: line.slice(0, sp), subject: line.slice(sp + 1) };
|
||||
});
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function deriveCurrentStep(progress, plan, gitCommits) {
|
||||
if (!progress || !progress.steps || gitCommits.length === 0) return null;
|
||||
const stored = progress.current_step || 0;
|
||||
let highestMatched = stored;
|
||||
for (const [stepN, prefix] of plan.entries()) {
|
||||
const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g, '')));
|
||||
if (matchedCommit && stepN > highestMatched) highestMatched = stepN;
|
||||
}
|
||||
return highestMatched;
|
||||
}
|
||||
|
||||
function repoRootOf(dir) {
|
||||
try {
|
||||
return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Resumable statuses for .session-state.local.json. `completed` is terminal;
|
||||
// `failed`/`stopped` are operator-action-required and should NOT be silently
|
||||
// refreshed by a background hook (would mask the alert). We only bump
|
||||
// updated_at for in_progress | partial — the active-work statuses.
|
||||
const SESSION_STATE_REFRESHABLE = new Set(['in_progress', 'partial']);
|
||||
|
||||
function refreshSessionState(projDir) {
|
||||
const statePath = join(projDir, '.session-state.local.json');
|
||||
if (!existsSync(statePath)) return false;
|
||||
const state = readJson(statePath);
|
||||
if (!state || typeof state !== 'object') return false;
|
||||
if (!SESSION_STATE_REFRESHABLE.has(state.status)) return false;
|
||||
// Monotonic guard: only mutate updated_at. Never touch status, project,
|
||||
// next_session_*. The writer (Phase 8 / helper) owns those fields.
|
||||
state.updated_at = new Date().toISOString();
|
||||
atomicWriteJson(statePath, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
let stdinPayload = '';
|
||||
try { stdinPayload = readFileSync(0, 'utf-8'); } catch { /* fine */ }
|
||||
|
||||
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const progressFiles = findProgressFiles(cwd);
|
||||
|
||||
if (progressFiles.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let mutationsMade = 0;
|
||||
for (const { projDir, progPath, planPath } of progressFiles) {
|
||||
const progress = readJson(progPath);
|
||||
if (!progress || progress.status === 'completed') continue;
|
||||
|
||||
const repoRoot = repoRootOf(projDir);
|
||||
if (!repoRoot) continue;
|
||||
|
||||
const plan = readPlanCheckpointPatterns(planPath);
|
||||
if (plan.size === 0) continue;
|
||||
|
||||
const sessionStart = progress.session_start_sha;
|
||||
if (!sessionStart) continue;
|
||||
|
||||
const commits = gitLog(repoRoot, sessionStart);
|
||||
const derivedStep = deriveCurrentStep(progress, plan, commits);
|
||||
|
||||
if (derivedStep !== null && derivedStep > (progress.current_step || 0)) {
|
||||
progress.current_step = derivedStep;
|
||||
progress.updated_at = new Date().toISOString();
|
||||
if (!progress.steps[String(derivedStep)]) {
|
||||
progress.steps[String(derivedStep)] = {
|
||||
status: 'completed', attempts: 1, error: null,
|
||||
completed_at: progress.updated_at, commit: null, manifest_audit: 'n/a',
|
||||
note: 'reconstructed by pre-compact-flush from git log',
|
||||
};
|
||||
}
|
||||
atomicWriteJson(progPath, progress);
|
||||
process.stderr.write(`[voyage] pre-compact flush: ${progPath} -> current_step=${derivedStep}\n`);
|
||||
mutationsMade++;
|
||||
}
|
||||
|
||||
// Sibling .session-state.local.json refresh (Handover 7). Independent of
|
||||
// progress.json mutation — the state file may exist for a session that
|
||||
// hasn't advanced step yet, and we still want updated_at to track liveness.
|
||||
if (refreshSessionState(projDir)) {
|
||||
process.stderr.write(`[voyage] pre-compact refresh: ${projDir}/.session-state.local.json\n`);
|
||||
mutationsMade++;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
125
plugins/voyage/hooks/scripts/pre-write-executor.mjs
Normal file
125
plugins/voyage/hooks/scripts/pre-write-executor.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-write-executor.mjs
|
||||
// Event: PreToolUse (Write)
|
||||
// Purpose: Block writes to security-sensitive paths during plan execution.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
// - tool_input.file_path — the target path for Write tool
|
||||
// - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets
|
||||
// - Allow (exit 0): everything else
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLOCK rules — path patterns that must never be written during execution.
|
||||
// ---------------------------------------------------------------------------
|
||||
const BLOCK_RULES = [
|
||||
{
|
||||
name: 'Git hook injection (.git/hooks/)',
|
||||
test: (p) => /\/\.git\/hooks\//.test(p),
|
||||
description:
|
||||
'Writing to .git/hooks/ could inject malicious git hooks that execute ' +
|
||||
'on every commit, push, or checkout. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Claude settings self-modification',
|
||||
test: (p) => /\/\.claude\/settings[^/]*\.json$/.test(p),
|
||||
description:
|
||||
'Writing to .claude/settings.json could disable security hooks or ' +
|
||||
'change permission modes. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Claude hooks self-modification',
|
||||
test: (p) => /\/\.claude\/hooks\//.test(p) || /\/\.claude-plugin\//.test(p),
|
||||
description:
|
||||
'Writing to .claude/hooks/ or .claude-plugin/ could modify security ' +
|
||||
'hook configuration. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Shell configuration files',
|
||||
test: (p) => {
|
||||
const sensitive = [
|
||||
`${HOME}/.zshrc`,
|
||||
`${HOME}/.bashrc`,
|
||||
`${HOME}/.bash_profile`,
|
||||
`${HOME}/.profile`,
|
||||
`${HOME}/.zshenv`,
|
||||
`${HOME}/.zprofile`,
|
||||
];
|
||||
const resolved = resolve(p);
|
||||
return sensitive.some((s) => resolved === s || resolved.startsWith(s + '.'));
|
||||
},
|
||||
description:
|
||||
'Writing to shell config files (~/.zshrc, ~/.bashrc, etc.) could inject ' +
|
||||
'persistent commands. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'SSH directory',
|
||||
test: (p) => {
|
||||
const resolved = resolve(p);
|
||||
return resolved.startsWith(`${HOME}/.ssh/`) || resolved === `${HOME}/.ssh`;
|
||||
},
|
||||
description: 'Writing to ~/.ssh/ could compromise SSH keys or config. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'AWS credentials',
|
||||
test: (p) => {
|
||||
const resolved = resolve(p);
|
||||
return resolved.startsWith(`${HOME}/.aws/`) || resolved === `${HOME}/.aws`;
|
||||
},
|
||||
description: 'Writing to ~/.aws/ could compromise cloud credentials. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'GnuPG directory',
|
||||
test: (p) => {
|
||||
const resolved = resolve(p);
|
||||
return resolved.startsWith(`${HOME}/.gnupg/`) || resolved === `${HOME}/.gnupg`;
|
||||
},
|
||||
description: 'Writing to ~/.gnupg/ could compromise GPG keys. Blocked.',
|
||||
},
|
||||
{
|
||||
name: 'Environment files (.env)',
|
||||
test: (p) => /\/\.env(?:\.[a-zA-Z0-9]+)?$/.test(p),
|
||||
description:
|
||||
'Writing to .env files could expose or modify secrets. Blocked. ' +
|
||||
'Use .env.template instead.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
let input;
|
||||
try {
|
||||
const raw = readFileSync(0, 'utf-8');
|
||||
input = JSON.parse(raw);
|
||||
} catch {
|
||||
// Cannot parse stdin — fail open.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = input?.tool_input?.file_path;
|
||||
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const resolved = resolve(filePath);
|
||||
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.test(resolved)) {
|
||||
process.stderr.write(
|
||||
`[voyage] BLOCKED: ${rule.name}\n` +
|
||||
` Path: ${resolved}\n` +
|
||||
` ${rule.description}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow
|
||||
process.exit(0);
|
||||
89
plugins/voyage/hooks/scripts/session-title.mjs
Executable file
89
plugins/voyage/hooks/scripts/session-title.mjs
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env node
|
||||
// session-title.mjs — UserPromptSubmit hook (CC v2.1.94+)
|
||||
//
|
||||
// Sets a sessionTitle when the user invokes one of the four voyage commands,
|
||||
// so multi-session headless runs are easy to identify in process lists and
|
||||
// session pickers.
|
||||
//
|
||||
// Title format: voyage:<command>:<slug>
|
||||
// - <command> ∈ {brief, research, plan, execute, review}
|
||||
// - <slug> ∈ first 30 chars of project slug, or "ad-hoc" when no
|
||||
// --project / --brief context is detected
|
||||
//
|
||||
// Fail-open invariant: any error → exit 0 with no output. We never block
|
||||
// the user's prompt.
|
||||
|
||||
import { stdin } from 'node:process';
|
||||
import { resolve, basename } from 'node:path';
|
||||
|
||||
const COMMANDS = {
|
||||
'/trekbrief': 'brief',
|
||||
'/trekresearch': 'research',
|
||||
'/trekplan': 'plan',
|
||||
'/trekexecute': 'execute',
|
||||
'/trekreview': 'review',
|
||||
'/trekcontinue': 'continue',
|
||||
'/trekendsession': 'endsession',
|
||||
};
|
||||
|
||||
function slugify(s) {
|
||||
return String(s)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 30) || 'ad-hoc';
|
||||
}
|
||||
|
||||
function detectSlug(prompt) {
|
||||
const projectMatch = prompt.match(/--project[=\s]+(\S+)/);
|
||||
if (projectMatch) {
|
||||
const dir = projectMatch[1].replace(/['"]/g, '');
|
||||
const base = basename(resolve(dir));
|
||||
const dateStripped = base.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
||||
return slugify(dateStripped);
|
||||
}
|
||||
const briefMatch = prompt.match(/--brief[=\s]+(\S+)/);
|
||||
if (briefMatch) {
|
||||
const file = briefMatch[1].replace(/['"]/g, '');
|
||||
return slugify(basename(file, '.md'));
|
||||
}
|
||||
return 'ad-hoc';
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
let data = '';
|
||||
for await (const chunk of stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) return;
|
||||
const payload = JSON.parse(raw);
|
||||
const prompt = String(payload.prompt || '').trim();
|
||||
if (!prompt) return;
|
||||
|
||||
let matchedCmd = null;
|
||||
for (const [cmd, short] of Object.entries(COMMANDS)) {
|
||||
if (prompt.startsWith(cmd)) {
|
||||
matchedCmd = short;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchedCmd) return;
|
||||
|
||||
const slug = detectSlug(prompt);
|
||||
const title = `voyage:${matchedCmd}:${slug}`;
|
||||
|
||||
const out = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
sessionTitle: title,
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out) + '\n');
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue