Skip to content

Commit b8dc79a

Browse files
committed
feat: add idea backup/history before processing
- Add ideasHistoryDir path (.opencode/opencoder/ideas/history/) - Create archiveIdea() function to copy ideas before removal - Archive idea to timestamped file (YYYYMMDD_HHMMSS_filename.md) - Ensure ideas history directory is created on startup - Add comprehensive tests for archiving functionality Signed-off-by: leocavalcante <[email protected]>
1 parent 4a1e9ae commit b8dc79a

File tree

6 files changed

+114
-4
lines changed

6 files changed

+114
-4
lines changed

src/fs.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const OPENCODER_SUBDIR = "opencoder"
1717
*/
1818
export function initializePaths(projectDir: string): Paths {
1919
const opencoderDir = join(resolve(projectDir), OPENCODE_DIR, OPENCODER_SUBDIR)
20+
const ideasDir = join(opencoderDir, "ideas")
2021

2122
return {
2223
opencoderDir,
@@ -26,7 +27,8 @@ export function initializePaths(projectDir: string): Paths {
2627
cycleLogDir: join(opencoderDir, "logs", "cycles"),
2728
alertsFile: join(opencoderDir, "alerts.log"),
2829
historyDir: join(opencoderDir, "history"),
29-
ideasDir: join(opencoderDir, "ideas"),
30+
ideasDir,
31+
ideasHistoryDir: join(ideasDir, "history"),
3032
configFile: join(opencoderDir, "config.json"),
3133
}
3234
}
@@ -43,6 +45,7 @@ export function ensureDirectories(paths: Paths): void {
4345
paths.cycleLogDir,
4446
paths.historyDir,
4547
paths.ideasDir,
48+
paths.ideasHistoryDir,
4649
]
4750

4851
for (const dir of directories) {

src/ideas.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* for the autonomous loop to work on.
66
*/
77

8-
import { existsSync, readdirSync, unlinkSync } from "node:fs"
8+
import { copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"
99
import { join } from "node:path"
1010
import type { Idea } from "./types.ts"
1111

@@ -117,6 +117,41 @@ export function removeIdea(ideaPath: string): boolean {
117117
}
118118
}
119119

120+
/**
121+
* Archive an idea file to the history directory before processing.
122+
* Creates a timestamped copy for future reference.
123+
* @param ideaPath - Path to the idea file to archive
124+
* @param historyDir - Directory to store archived ideas
125+
* @returns The path to the archived file, or null if archiving failed
126+
*/
127+
export function archiveIdea(ideaPath: string, historyDir: string): string | null {
128+
try {
129+
if (!existsSync(ideaPath)) {
130+
return null
131+
}
132+
133+
// Ensure history directory exists
134+
if (!existsSync(historyDir)) {
135+
mkdirSync(historyDir, { recursive: true })
136+
}
137+
138+
// Generate timestamped filename: YYYYMMDD_HHMMSS_originalname.md
139+
const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "_").slice(0, 15)
140+
const originalFilename = ideaPath.split("/").pop() || "idea.md"
141+
const archiveFilename = `${timestamp}_${originalFilename}`
142+
const archivePath = join(historyDir, archiveFilename)
143+
144+
copyFileSync(ideaPath, archivePath)
145+
return archivePath
146+
} catch (err) {
147+
// Failed to archive idea file
148+
if (process.env.DEBUG) {
149+
console.debug(`[ideas] Failed to archive ${ideaPath}: ${err}`)
150+
}
151+
return null
152+
}
153+
}
154+
120155
/**
121156
* Remove an idea by index from a list
122157
*/

src/loop.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import {
1919
writeFile,
2020
} from "./fs.ts"
2121
import { commitChanges, generateCommitMessage, hasChanges, pushChanges } from "./git.ts"
22-
import { formatIdeasForSelection, loadAllIdeas, parseIdeaSelection, removeIdea } from "./ideas.ts"
22+
import {
23+
archiveIdea,
24+
formatIdeasForSelection,
25+
loadAllIdeas,
26+
parseIdeaSelection,
27+
removeIdea,
28+
} from "./ideas.ts"
2329
import { Logger } from "./logger.ts"
2430
import { getTasks, getUncompletedTasks, markTaskComplete, validatePlan } from "./plan.ts"
2531
import { loadState, saveState } from "./state.ts"
@@ -310,8 +316,14 @@ async function runPlanPhase(
310316
const tasks = getTasks(planContent)
311317
logger.success(`Plan created with ${tasks.length} tasks`)
312318

313-
// Only NOW remove the idea file, after plan is safely saved
319+
// Archive and remove the idea file, after plan is safely saved
314320
if (ideaToRemove) {
321+
// Archive to history before removing
322+
const archivePath = archiveIdea(ideaToRemove.path, paths.ideasHistoryDir)
323+
if (archivePath) {
324+
logger.logVerbose(`Archived idea to: ${archivePath}`)
325+
}
326+
315327
const removed = removeIdea(ideaToRemove.path)
316328
if (removed) {
317329
logger.logVerbose(`Removed processed idea: ${ideaToRemove.filename}`)

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export interface Paths {
103103
historyDir: string
104104
/** .opencoder/ideas/ */
105105
ideasDir: string
106+
/** .opencoder/ideas/history/ */
107+
ideasHistoryDir: string
106108
/** opencoder.json config file */
107109
configFile: string
108110
}

tests/fs.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe("fs", () => {
4949
expect(paths.alertsFile).toBe("/project/.opencode/opencoder/alerts.log")
5050
expect(paths.historyDir).toBe("/project/.opencode/opencoder/history")
5151
expect(paths.ideasDir).toBe("/project/.opencode/opencoder/ideas")
52+
expect(paths.ideasHistoryDir).toBe("/project/.opencode/opencoder/ideas/history")
5253
expect(paths.configFile).toBe("/project/.opencode/opencoder/config.json")
5354
})
5455

@@ -70,6 +71,7 @@ describe("fs", () => {
7071
expect(existsSync(paths.cycleLogDir)).toBe(true)
7172
expect(existsSync(paths.historyDir)).toBe(true)
7273
expect(existsSync(paths.ideasDir)).toBe(true)
74+
expect(existsSync(paths.ideasHistoryDir)).toBe(true)
7375
})
7476

7577
test("handles existing directories", () => {

tests/ideas.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
66
import { existsSync, mkdirSync, rmSync } from "node:fs"
77
import { join } from "node:path"
88
import {
9+
archiveIdea,
910
countIdeas,
1011
formatIdeasForSelection,
1112
getIdeaSummary,
@@ -163,4 +164,59 @@ describe("ideas", () => {
163164
expect(result).toBe(false)
164165
})
165166
})
167+
168+
describe("archiveIdea", () => {
169+
test("archives idea to history directory", async () => {
170+
const ideaPath = join(TEST_DIR, "my-idea.md")
171+
const historyDir = join(TEST_DIR, "history")
172+
await Bun.write(ideaPath, "# Test Idea\nContent here")
173+
174+
const archivePath = archiveIdea(ideaPath, historyDir)
175+
176+
expect(archivePath).not.toBeNull()
177+
if (!archivePath) return // Type guard
178+
expect(existsSync(archivePath)).toBe(true)
179+
expect(archivePath).toContain("_my-idea.md")
180+
// Original file should still exist
181+
expect(existsSync(ideaPath)).toBe(true)
182+
183+
// Verify content was copied
184+
const archivedContent = await Bun.file(archivePath).text()
185+
expect(archivedContent).toBe("# Test Idea\nContent here")
186+
})
187+
188+
test("creates history directory if it doesn't exist", async () => {
189+
const ideaPath = join(TEST_DIR, "idea.md")
190+
const historyDir = join(TEST_DIR, "new-history-dir")
191+
await Bun.write(ideaPath, "content")
192+
193+
expect(existsSync(historyDir)).toBe(false)
194+
195+
const archivePath = archiveIdea(ideaPath, historyDir)
196+
197+
expect(archivePath).not.toBeNull()
198+
expect(existsSync(historyDir)).toBe(true)
199+
})
200+
201+
test("returns null for non-existent idea", () => {
202+
const historyDir = join(TEST_DIR, "history")
203+
const archivePath = archiveIdea("/nonexistent/idea.md", historyDir)
204+
205+
expect(archivePath).toBeNull()
206+
})
207+
208+
test("generates timestamped filename", async () => {
209+
const ideaPath = join(TEST_DIR, "feature.md")
210+
const historyDir = join(TEST_DIR, "history")
211+
await Bun.write(ideaPath, "content")
212+
213+
const archivePath = archiveIdea(ideaPath, historyDir)
214+
215+
expect(archivePath).not.toBeNull()
216+
if (!archivePath) return // Type guard
217+
// Should match pattern: YYYYMMDD_HHMMSS_filename.md
218+
const filename = archivePath.split("/").pop()
219+
expect(filename).toMatch(/^\d{8}_\d{6}_feature\.md$/)
220+
})
221+
})
166222
})

0 commit comments

Comments
 (0)