Status: Draft v5 Date: 2026-07-17 Based on: 8 adversarial reviews
issues.db is gitignored. Issue history is not version-controlled. Fresh clones lose all issue state.
- mtime-based lazy snapshot — broken due to SQLite WAL mode
post_command_hooksin hook-config.json — does not exist in crosslink
Trigger: Detect crosslink issue mutation commands in tool.execute.before
Action: After command completes, export issues to tracked JSON
Safety: Atomic write (temp + rename), debounce, concurrency guard, chain-aware detection
tool.execute.before fires
│
├─ Is this a crosslink mutation command? (chain-aware)
│ ├─ No → existing guard logic proceeds
│ └─ Yes → schedule deferred export (1000ms debounce)
│
└─ Meanwhile, foreground bash runs the actual mutation
│
▼
issues.db updated
│
1000ms debounce expires
│
▼
Export fires (atomic write)
│
▼
.crosslink/issues-snapshot.json ← git-tracked
Based on IssueCommands enum in main.rs:574-863 and top-level aliases:
const CROSSLINK_MUTATION_VERBS = [
// Issue lifecycle
"issue create", "issue quick", "quick",
"issue close", "close", "close-all",
"issue reopen", "reopen",
"issue delete", "delete",
// Issue content
"issue comment", "comment",
"issue edit", "edit",
"issue label", "label", "unlabel",
"issue assign", "issue unassign",
// Issue relationships
"issue depend", "undepend",
"issue relate", "unrelate",
"issue block", "unblock",
// Issue scheduling
"issue schedule", "unschedule",
// Bulk operations
"import",
"intervene", "tested",
// Milestones
"milestone create", "milestone close", "milestone edit",
"milestone issue add", "milestone issue remove",
// Top-level aliases
"new", "subissue",
];
function isCrosslinkMutation(command: string): boolean {
let cmd = command;
// Strip rtk prefix
while (cmd.startsWith("rtk ")) cmd = cmd.slice(4);
// Strip crosslink global flags
cmd = cmd.replace(/^crosslink\s+(?:-\S+\s+)*/, "crosslink ");
// Handle chained commands: split on &&, ;, |
const parts = cmd.split(/\s*&&\s*|\s*;\s*|\s*\|\s*/);
return parts.some(part => {
const trimmed = part.trim();
return CROSSLINK_MUTATION_VERBS.some(v =>
trimmed.startsWith(`crosslink ${v}`)
);
});
}const SNAPSHOT_FILE = "issues-snapshot.json";
const DEBOUNCE_MS = 1000; // Increased from 500ms to handle slow mutations
let exportTimer: NodeJS.Timeout | null = null;
let exportInProgress = false;
function scheduleSnapshotExport(crosslinkDir: string): void {
if (exportTimer) clearTimeout(exportTimer);
exportTimer = setTimeout(() => {
runSnapshotExport(crosslinkDir);
}, DEBOUNCE_MS);
}
async function runSnapshotExport(crosslinkDir: string): Promise<void> {
if (exportInProgress) return;
exportInProgress = true;
try {
const snapshotPath = path.join(crosslinkDir, SNAPSHOT_FILE);
const tmpPath = snapshotPath + ".tmp";
const result = await runCrosslink(
shell,
["export", "--format", "json", "--output", `"${tmpPath}"`],
crosslinkDir,
);
if (result?.exitCode === 0) {
// Atomic write: rename temp → final
fs.renameSync(tmpPath, snapshotPath);
log("Exported issue snapshot to:", snapshotPath);
} else {
log("Issue snapshot export failed (exit:", result?.exitCode ?? "null", ")");
try { fs.unlinkSync(tmpPath); } catch {}
}
} catch (e) {
log("Snapshot export error:", String(e));
} finally {
exportInProgress = false;
}
}Insert in tool.execute.before, inside the write/edit/bash guard:
if (toolLower === "bash" && crosslinkDir) {
const command = (output.args?.command as string) ?? "";
if (isCrosslinkMutation(command)) {
scheduleSnapshotExport(crosslinkDir);
// Do NOT block — let the mutation proceed
}
}.crosslink/issues-snapshot.jsonis NOT gitignored (verified)- Use
git add -fin initial seed to force-add even if gitignore patterns match
After deployment, run once to create initial snapshot:
cd /home/claude-code/projects/ASES
crosslink export --format json --output .crosslink/issues-snapshot.json
git add -f .crosslink/issues-snapshot.json
git commit -m "chore: seed issue snapshot for version tracking"| Case | Handling |
|---|---|
Missing .crosslink/ |
findCrosslinkDir returns null → skip |
Missing issues.db |
crosslink export produces [] → harmless |
| Concurrent mutations | Debounce coalesces rapid changes; single export after last write |
| Chained commands | Split on &&, ;, ` |
| Slow mutations | 1000ms debounce gives more time; next mutation retries if stale |
| Export fails | Logged, temp file cleaned up, next mutation retries |
| Large DB | Export may be slow → still only fires on mutation, not every call |
crosslink binary missing |
runCrosslink returns non-zero → logged, no crash |
| Paths with spaces | Quote output path unconditionally |
In scope: Issue mutations that change issues.db
Out of scope: Session state changes (session work, session end), timer operations, read-only commands
.opencode/plugins/crosslink-guard.ts— add ~100 lines (mutation detection + debounced export).crosslink/issues-snapshot.json— initial seed (tracked in git)
- Remove new functions from
crosslink-guard.ts git rm .crosslink/issues-snapshot.json
- Works within actual constraints (
tool.execute.beforeonly) - No mtime detection — uses command-pattern (reliable)
- Chain-aware — handles
crosslink issue close 5 && git add . - Atomic write prevents corruption
- Debounce prevents thundering herd
- Uses crosslink's own export command (format consistency)
- ~100 lines, no new dependencies
- 1000ms latency after mutation (acceptable for VCS)
- Exports full DB on every mutation (could be slow for very large DBs)
- Requires
crosslinkon PATH - First export after slow mutation may be stale (self-healing on next mutation)