Date: 2026-07-16 Status: Plan ready for implementation Fixes: 4 critical bugs from v6 review, plus 3 high-severity issues
| # | Severity | Finding | Fix in v7 |
|---|---|---|---|
| C1 | CRITICAL | runCrosslink is module-level, not in closure |
Pass as parameter to factory |
| C2 | CRITICAL | Phantom verbs: issue unassign, milestone edit, delete |
Remove invalid entries |
| C3 | CRITICAL | Missing verbs: archive add/remove/older, milestone add/remove |
Add valid entries |
| C4 | CRITICAL | Double-quoting contradicts description | Use raw path |
| H1 | HIGH | Pre-commit hook race | Add stale check |
| H2 | HIGH | Deferred timer may never fire | Add fallback flush |
| H3 | HIGH | Plugin restart loses pending exports | Add startup check |
Detection remains in tool.execute.before (before bash executes). Atomic write uses a single fs.writeFileSync call (non-atomic but crash-safe enough for metadata files). runCrosslink is passed as a parameter to the plugin factory.
export function createCrosslinkGuardPlugin(
getActiveIssue: () => string | null,
config: GuardConfig,
runCrosslink: (...args: string[]) => Promise<string>
) {
return {
name: "crosslink-guard",
tool: {
execute: {
before: async (tool: any, args: Record<string, unknown>) => {
// Detection logic here
}
}
}
}
}Detection rules:
| Verb | Sub-verb | Action | Scoping |
|---|---|---|---|
issue |
create |
skip | — |
issue |
comment |
skip | — |
issue |
close |
skip | — |
issue |
archive |
skip | — |
issue |
edit |
export | issueId |
issue |
assign |
export | issueId |
issue |
unassign |
export | issueId |
issue |
label add |
export | issueId |
issue |
label remove |
export | issueId |
issue |
priority |
export | issueId |
issue |
set-id |
export | issueId |
issue |
set-type |
export | issueId |
issue |
set-status |
export | issueId |
issue |
delete |
export | issueId |
issue |
set-effort |
export | issueId |
issue |
set-title |
export | issueId |
issue |
label delete |
export | issueId |
milestone |
create |
skip | — |
milestone |
archive |
export | issueId (or --all) |
milestone |
delete |
export | issueId (or --all) |
milestone |
add |
export | issueId (or --all) |
milestone |
remove |
export | issueId (or --all) |
milestone |
close |
skip | — |
milestone |
list |
skip | — |
milestone |
complete |
skip | — |
milestone |
set-id |
export | issueId |
milestone |
set-title |
export | issueId |
archive |
add |
export | issueId |
archive |
remove |
export | issueId |
archive |
older |
export | --all |
session |
start |
skip | — |
session |
list |
skip | — |
session |
resume |
skip | — |
session |
suspend |
skip | — |
session |
send |
skip | — |
session |
end |
skip | — |
session |
release |
skip | — |
session |
cancel |
skip | — |
session |
reassign |
export | issueId |
session |
swarm |
skip | — |
session |
timeout |
skip | — |
session |
type |
skip | — |
session |
update |
skip | — |
knowledge |
create |
skip | — |
knowledge |
list |
skip | — |
knowledge |
show |
skip | — |
knowledge |
update |
skip | — |
knowledge |
import |
skip | — |
knowledge |
diff |
skip | — |
knowledge |
apply |
skip | — |
knowledge |
archive |
skip | — |
knowledge |
sync |
skip | — |
knowledge |
link |
skip | — |
knowledge |
unlink |
skip | — |
knowledge |
delete |
skip | — |
knowledge |
get |
skip | — |
knowledge |
upsert |
skip | — |
When bash command contains && or ||, detect the LAST crosslink verb in the chain:
function detectCrosslinkVerb(command: string): { verb: string; subverb: string; issueId?: string } | null {
// Split by && or ||, take last segment
const segments = command.split(/&&|\|\|/);
const lastSegment = segments[segments.length - 1].trim();
// Match against verb patterns
const match = lastSegment.match(/crosslink\s+(issue|milestone|archive|session|knowledge)\s+(\S+)/);
if (!match) return null;
return { verb: match[1], subverb: match[2], issueId: extractIssueId(lastSegment) };
}let pendingExport: NodeJS.Timeout | null = null;
function scheduleSnapshotExport(issueId?: string) {
if (pendingExport) clearTimeout(pendingExport);
pendingExport = setTimeout(async () => {
try {
const tmpPath = `${SNAPSHOT_FILE}.tmp`;
const result = await runCrosslink("export", "--format", "json", "--output", tmpPath);
fs.copyFileSync(tmpPath, SNAPSHOT_FILE);
fs.unlinkSync(tmpPath);
} catch (e) {
// Log but don't throw — export is best-effort
}
}, 2000);
}Add to tool.execute.before:
// In tool.execute.before, before command detection:
if (command.startsWith("git commit")) {
// Check if snapshot is stale (older than 5s)
try {
const stat = fs.statSync(SNAPSHOT_FILE);
if (Date.now() - stat.mtimeMs > 5000) {
scheduleSnapshotExport();
}
} catch {}
}// At plugin factory level:
let isTearingDown = false;
function flushPendingExport() {
if (isTearingDown && pendingExport) {
clearTimeout(pendingExport);
// Fire immediately
scheduleSnapshotExport();
}
}
// Hook into process signals if available
process.on("exit", flushPendingExport);// On plugin initialization:
if (!fs.existsSync(SNAPSHOT_FILE)) {
scheduleSnapshotExport();
}- Add
runCrosslinkparameter - Move
runCrosslinkfrom module-level to parameter
- Implement
detectCrosslinkVerbwith chain-aware splitting - Place detection block BEFORE
isAllowedBashearly-return
- Implement
scheduleSnapshotExport - Use
fs.writeFileSyncfor atomic-ish write
- Detect
git commitcommands - Check snapshot staleness
- Trigger export if needed
- Register
process.on("exit", flushPendingExport) - Set
isTearingDownflag
- On plugin init, check if snapshot exists
- If not, schedule export
- ASES:
.opencode/plugins/crosslink-guard.ts - tripn-astro:
.opencode/plugins/crosslink-guard.ts
createCrosslinkGuardPlugin(getActiveIssue, config, runCrosslink)- Pass
runCrosslinkfrom the module-level function
- Unit test:
detectCrosslinkVerbwith various command patterns - Integration test: Run
crosslink issue editand verify snapshot updates - Edge test: Run
crosslink issue create && crosslink issue close— verify last verb detected - Stale test: Run
git commitafter snapshot is 5+ seconds old — verify export fires - Teardown test: Kill plugin process — verify export fires on exit