Skip to content

Commit c2ba70d

Browse files
Aleschkaclaude
andcommitted
fix: protect job frontmatter from being overwritten during execution
Snapshots job file frontmatter before Claude runs and restores it if Claude modifies or strips it during execution. Fixes #1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a5dc03b commit c2ba70d

File tree

2 files changed

+78
-18
lines changed

2 files changed

+78
-18
lines changed

src/commands/start.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fileURLToPath } from "url";
44
import { run, runUserMessage, bootstrap, ensureProjectClaudeMd, loadHeartbeatPromptTemplate } from "../runner";
55
import { writeState, type StateData } from "../statusline";
66
import { cronMatches, nextCronMatch } from "../cron";
7-
import { clearJobSchedule, loadJobs } from "../jobs";
7+
import { clearJobSchedule, loadJobs, snapshotJobFrontmatter } from "../jobs";
88
import { writePidFile, cleanupPidFile, checkExistingDaemon } from "../pid";
99
import { initConfig, loadSettings, reloadSettings, resolvePrompt, type HeartbeatConfig, type Settings } from "../config";
1010
import { getDayAndMinuteAtOffset } from "../timezone";
@@ -693,23 +693,30 @@ export async function start(args: string[] = []) {
693693
const now = new Date();
694694
for (const job of currentJobs) {
695695
if (cronMatches(job.schedule, now, currentSettings.timezoneOffsetMinutes)) {
696-
resolvePrompt(job.prompt)
697-
.then((prompt) => run(job.name, prompt))
698-
.then((r) => {
699-
if (job.notify === false) return;
700-
if (job.notify === "error" && r.exitCode === 0) return;
701-
forwardToTelegram(job.name, r);
702-
forwardToDiscord(job.name, r);
703-
})
704-
.finally(async () => {
705-
if (job.recurring) return;
706-
try {
707-
await clearJobSchedule(job.name);
708-
console.log(`[${ts()}] Cleared schedule for one-time job: ${job.name}`);
709-
} catch (err) {
710-
console.error(`[${ts()}] Failed to clear schedule for ${job.name}:`, err);
711-
}
712-
});
696+
snapshotJobFrontmatter(job.name)
697+
.then((restoreFrontmatter) =>
698+
resolvePrompt(job.prompt)
699+
.then((prompt) => run(job.name, prompt))
700+
.then(async (r) => {
701+
const restored = await restoreFrontmatter();
702+
if (restored) {
703+
console.log(`[${ts()}] Restored frontmatter for job: ${job.name}`);
704+
}
705+
if (job.notify === false) return;
706+
if (job.notify === "error" && r.exitCode === 0) return;
707+
forwardToTelegram(job.name, r);
708+
forwardToDiscord(job.name, r);
709+
})
710+
.finally(async () => {
711+
if (job.recurring) return;
712+
try {
713+
await clearJobSchedule(job.name);
714+
console.log(`[${ts()}] Cleared schedule for one-time job: ${job.name}`);
715+
} catch (err) {
716+
console.error(`[${ts()}] Failed to clear schedule for ${job.name}:`, err);
717+
}
718+
})
719+
);
713720
}
714721
}
715722
updateState();

src/jobs.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,59 @@ export async function loadJobs(): Promise<Job[]> {
7272
return jobs;
7373
}
7474

75+
/**
76+
* Snapshot a job file's frontmatter before execution.
77+
* Returns a restore function that re-applies the original frontmatter
78+
* if Claude overwrote or stripped it during the run.
79+
*/
80+
export async function snapshotJobFrontmatter(
81+
jobName: string
82+
): Promise<() => Promise<boolean>> {
83+
const path = join(JOBS_DIR, `${jobName}.md`);
84+
let originalContent: string;
85+
try {
86+
originalContent = await Bun.file(path).text();
87+
} catch {
88+
return async () => false;
89+
}
90+
91+
const originalMatch = originalContent.match(
92+
/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/
93+
);
94+
if (!originalMatch) return async () => false;
95+
96+
const originalFrontmatter = originalMatch[1];
97+
98+
return async () => {
99+
let currentContent: string;
100+
try {
101+
currentContent = await Bun.file(path).text();
102+
} catch {
103+
return false;
104+
}
105+
106+
const currentMatch = currentContent.match(
107+
/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/
108+
);
109+
110+
// File completely mangled — restore entire original
111+
if (!currentMatch) {
112+
await Bun.write(path, originalContent);
113+
return true;
114+
}
115+
116+
// Frontmatter was modified — restore it, keep current body
117+
if (currentMatch[1].trim() !== originalFrontmatter.trim()) {
118+
const restoredBody = currentMatch[2].trim();
119+
const restored = `---\n${originalFrontmatter}\n---\n${restoredBody}\n`;
120+
await Bun.write(path, restored);
121+
return true;
122+
}
123+
124+
return false;
125+
};
126+
}
127+
75128
export async function clearJobSchedule(jobName: string): Promise<void> {
76129
const path = join(JOBS_DIR, `${jobName}.md`);
77130
const content = await Bun.file(path).text();

0 commit comments

Comments
 (0)