@causyne/agent 0.0.0-stage → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +31 -2
  3. package/SECURITY.md +24 -0
  4. package/bin/causyne.js +13 -0
  5. package/docs/ARCHITECTURE.md +63 -0
  6. package/docs/PROVENANCE.md +29 -0
  7. package/docs/SECURITY_REVIEW.md +89 -0
  8. package/docs/THIRD_PARTY_NOTICES.md +49 -0
  9. package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
  10. package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
  11. package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
  12. package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
  13. package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
  14. package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
  15. package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
  16. package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
  17. package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
  18. package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
  19. package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
  20. package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
  21. package/native-skills/android/play-store-release/SKILL.md +43 -0
  22. package/native-skills/android/room-and-persistence/SKILL.md +40 -0
  23. package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
  24. package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
  25. package/native-skills/backend/caching-strategy/SKILL.md +44 -0
  26. package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
  27. package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
  28. package/native-skills/backend/rest-api-design/SKILL.md +40 -0
  29. package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
  30. package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
  31. package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
  32. package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
  33. package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
  34. package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
  35. package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
  36. package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
  37. package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
  38. package/native-skills/data/metric-definition/SKILL.md +42 -0
  39. package/native-skills/data/notebook-to-production/SKILL.md +43 -0
  40. package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
  41. package/native-skills/database/connection-pooling/SKILL.md +42 -0
  42. package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
  43. package/native-skills/database/query-optimisation/SKILL.md +38 -0
  44. package/native-skills/database/safe-migrations/SKILL.md +47 -0
  45. package/native-skills/database/schema-design/SKILL.md +38 -0
  46. package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
  47. package/native-skills/design/colour-system-construction/SKILL.md +38 -0
  48. package/native-skills/design/design-direction-brief/SKILL.md +41 -0
  49. package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
  50. package/native-skills/design/responsive-strategy/SKILL.md +39 -0
  51. package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
  52. package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
  53. package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
  54. package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
  55. package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
  56. package/native-skills/devops/incident-triage/SKILL.md +38 -0
  57. package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
  58. package/native-skills/devops/observability-baseline/SKILL.md +38 -0
  59. package/native-skills/devops/secret-management/SKILL.md +40 -0
  60. package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
  61. package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
  62. package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
  63. package/native-skills/git/branch-strategy/SKILL.md +39 -0
  64. package/native-skills/git/commit-hygiene/SKILL.md +41 -0
  65. package/native-skills/git/history-recovery/SKILL.md +44 -0
  66. package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
  67. package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
  68. package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
  69. package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
  70. package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
  71. package/native-skills/mobile/deep-linking/SKILL.md +39 -0
  72. package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
  73. package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
  74. package/native-skills/mobile/push-notifications/SKILL.md +38 -0
  75. package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
  76. package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
  77. package/native-skills/performance/load-testing/SKILL.md +42 -0
  78. package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
  79. package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
  80. package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
  81. package/native-skills/python/async-correctness/SKILL.md +40 -0
  82. package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
  83. package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
  84. package/native-skills/python/environment-isolation/SKILL.md +41 -0
  85. package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
  86. package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
  87. package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
  88. package/native-skills/security/authorization-model/SKILL.md +42 -0
  89. package/native-skills/security/crypto-usage/SKILL.md +48 -0
  90. package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
  91. package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
  92. package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
  93. package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
  94. package/native-skills/security/threat-modelling/SKILL.md +46 -0
  95. package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
  96. package/native-skills/testing/property-based-testing/SKILL.md +46 -0
  97. package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
  98. package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
  99. package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
  100. package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
  101. package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
  102. package/native-skills/web/accessible-components/SKILL.md +45 -0
  103. package/native-skills/web/api-client-resilience/SKILL.md +39 -0
  104. package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
  105. package/native-skills/web/core-web-vitals/SKILL.md +40 -0
  106. package/native-skills/web/css-layout-systems/SKILL.md +41 -0
  107. package/native-skills/web/form-validation-ux/SKILL.md +39 -0
  108. package/native-skills/web/react-state-architecture/SKILL.md +41 -0
  109. package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
  110. package/package.json +68 -4
  111. package/src/agentLoop.js +2458 -0
  112. package/src/appChannel.js +109 -0
  113. package/src/approvalGrants.js +66 -0
  114. package/src/auditLog.js +52 -0
  115. package/src/autoUpdate.js +12 -0
  116. package/src/capabilities.js +36 -0
  117. package/src/checkpoints.js +1199 -0
  118. package/src/cliMode.js +638 -0
  119. package/src/commands.js +805 -0
  120. package/src/compiledSkills.js +540 -0
  121. package/src/config.js +130 -0
  122. package/src/contextManager.js +498 -0
  123. package/src/cortex/autoCondenser.js +192 -0
  124. package/src/cortex/provisioner.js +27 -0
  125. package/src/cortex/vectorDb.js +447 -0
  126. package/src/designPalettes.js +99 -0
  127. package/src/devMode.js +6 -0
  128. package/src/embodimentSession.js +180 -0
  129. package/src/entrypoint.js +194 -0
  130. package/src/execSafe.js +21 -0
  131. package/src/gitSnapshot.js +378 -0
  132. package/src/mcp/jsonRpc.js +165 -0
  133. package/src/mcp/mcpClient.js +136 -0
  134. package/src/mcp/mcpRegistry.js +207 -0
  135. package/src/memory.js +62 -0
  136. package/src/memoryManager.js +124 -0
  137. package/src/modelCatalogue.js +132 -0
  138. package/src/modelIds.js +42 -0
  139. package/src/modelRouter.js +145 -0
  140. package/src/outputStore.js +246 -0
  141. package/src/perception.js +124 -0
  142. package/src/planCache.js +437 -0
  143. package/src/procSignal.js +32 -0
  144. package/src/providers/anthropic.js +408 -0
  145. package/src/providers/base.js +328 -0
  146. package/src/providers/dashscope-byok.js +86 -0
  147. package/src/providers/deepseek.js +80 -0
  148. package/src/providers/factory.js +37 -0
  149. package/src/providers/gemini.js +346 -0
  150. package/src/providers/moonshot.js +73 -0
  151. package/src/providers/muse.js +169 -0
  152. package/src/providers/ollama.js +104 -0
  153. package/src/providers/openai-compatible.js +84 -0
  154. package/src/providers/openai.js +20 -0
  155. package/src/providers/qwen-byok.js +76 -0
  156. package/src/providers/xai.js +73 -0
  157. package/src/provisioning/capabilityConsent.js +24 -0
  158. package/src/provisioning/capabilityProvisioner.js +271 -0
  159. package/src/provisioning/capabilityTools.js +29 -0
  160. package/src/reporting.js +373 -0
  161. package/src/sandbox.js +529 -0
  162. package/src/sessionStore.js +279 -0
  163. package/src/skillsManager.js +288 -0
  164. package/src/slopDetector.js +307 -0
  165. package/src/taskBuffer.js +45 -0
  166. package/src/taskShape.js +241 -0
  167. package/src/textDiff.js +150 -0
  168. package/src/tokenTracker.js +560 -0
  169. package/src/tool-scripts/ax_snapshot.applescript +39 -0
  170. package/src/tool-scripts/ax_snapshot.ps1 +10 -0
  171. package/src/tool-scripts/desktop_act.applescript +58 -0
  172. package/src/tool-scripts/resolve_automator.py +119 -0
  173. package/src/toolPacks.js +524 -0
  174. package/src/tools/archive.js +63 -0
  175. package/src/tools/astryxOracle.js +78 -0
  176. package/src/tools/axSnapshot.js +165 -0
  177. package/src/tools/blenderAutomator.js +349 -0
  178. package/src/tools/browserAutomator.js +252 -0
  179. package/src/tools/checkSystemSpecs.js +64 -0
  180. package/src/tools/clipboard.js +36 -0
  181. package/src/tools/codeSearchAst.js +101 -0
  182. package/src/tools/codeSearcher.js +230 -0
  183. package/src/tools/codeTools.js +262 -0
  184. package/src/tools/codebaseSearch.js +75 -0
  185. package/src/tools/createSkill.js +129 -0
  186. package/src/tools/deferredMcpSearch.js +246 -0
  187. package/src/tools/desktopAct.js +208 -0
  188. package/src/tools/desktopOpen.js +34 -0
  189. package/src/tools/dynamicLoader.js +44 -0
  190. package/src/tools/executeProceduralMedia.js +93 -0
  191. package/src/tools/fsDelete.js +51 -0
  192. package/src/tools/fsEdit.js +105 -0
  193. package/src/tools/fsFind.js +128 -0
  194. package/src/tools/fsList.js +77 -0
  195. package/src/tools/fsMove.js +57 -0
  196. package/src/tools/fsRead.js +87 -0
  197. package/src/tools/fsSearch.js +198 -0
  198. package/src/tools/fsWrite.js +41 -0
  199. package/src/tools/gameAiBehaviorTree.js +208 -0
  200. package/src/tools/gameAnimationRig.js +194 -0
  201. package/src/tools/gameAssetFetcher.js +372 -0
  202. package/src/tools/gameAssetHarvester.js +210 -0
  203. package/src/tools/gameAudioSynth.js +267 -0
  204. package/src/tools/gameBuilder.js +886 -0
  205. package/src/tools/gameCanvasAudit.js +101 -0
  206. package/src/tools/gameCinematicSequencer.js +151 -0
  207. package/src/tools/gameDataForge.js +124 -0
  208. package/src/tools/gameDialogueTreeGen.js +166 -0
  209. package/src/tools/gameEconomyBalancer.js +161 -0
  210. package/src/tools/gameEntitySpawner.js +207 -0
  211. package/src/tools/gameLoreForge.js +153 -0
  212. package/src/tools/gameQuestArcBuilder.js +152 -0
  213. package/src/tools/gameSceneBuilder.js +158 -0
  214. package/src/tools/gameShaderForge.js +181 -0
  215. package/src/tools/gameTilemapGenerator.js +164 -0
  216. package/src/tools/gameUiHudBuilder.js +292 -0
  217. package/src/tools/gameVfxEmitterGen.js +108 -0
  218. package/src/tools/gameWebRuntimeInspector.js +91 -0
  219. package/src/tools/generateMediaViaApi.js +358 -0
  220. package/src/tools/gitExec.js +54 -0
  221. package/src/tools/godotAutomator.js +437 -0
  222. package/src/tools/manageLocalAiDependencies.js +220 -0
  223. package/src/tools/memoryWrite.js +99 -0
  224. package/src/tools/multiEdit.js +107 -0
  225. package/src/tools/palmierAutomator.js +104 -0
  226. package/src/tools/pdfReader.js +112 -0
  227. package/src/tools/persistentShell.js +148 -0
  228. package/src/tools/printer.js +316 -0
  229. package/src/tools/procTerm.js +91 -0
  230. package/src/tools/processList.js +52 -0
  231. package/src/tools/readNativeSkill.js +39 -0
  232. package/src/tools/registry.js +277 -0
  233. package/src/tools/resolveAutomator.js +102 -0
  234. package/src/tools/screenshot.js +75 -0
  235. package/src/tools/subagentOrchestrator.js +305 -0
  236. package/src/tools/sysRun.js +125 -0
  237. package/src/tools/todoWrite.js +75 -0
  238. package/src/tools/toolDispatch.js +127 -0
  239. package/src/tools/toolForge.js +124 -0
  240. package/src/tools/updatePlan.js +88 -0
  241. package/src/tools/viewImage.js +82 -0
  242. package/src/tools/visionInspector.js +154 -0
  243. package/src/tools/webFetch.js +74 -0
  244. package/src/tools/webSearch.js +82 -0
  245. package/src/tui/ask.js +164 -0
  246. package/src/tui/banner.js +158 -0
  247. package/src/tui/block.js +152 -0
  248. package/src/tui/chatRenderer.js +953 -0
  249. package/src/tui/completion.js +424 -0
  250. package/src/tui/dialogs.js +606 -0
  251. package/src/tui/diff.js +296 -0
  252. package/src/tui/history.js +135 -0
  253. package/src/tui/home.js +217 -0
  254. package/src/tui/invocation.js +105 -0
  255. package/src/tui/keys.js +443 -0
  256. package/src/tui/lineEditor.js +256 -0
  257. package/src/tui/overlay.js +256 -0
  258. package/src/tui/prompt.js +1258 -0
  259. package/src/tui/selectList.js +335 -0
  260. package/src/tui/stage.js +330 -0
  261. package/src/tui/stageSurface.js +609 -0
  262. package/src/tui/statusLine.js +254 -0
  263. package/src/tui/text.js +345 -0
  264. package/src/tui/theme.js +393 -0
  265. package/src/tui/toolDisplay.js +176 -0
  266. package/src/tui/transcript.js +235 -0
  267. package/src/tui/viewport.js +337 -0
  268. package/src/uncertainty.js +271 -0
  269. package/src/userSkills.js +265 -0
  270. package/src/utils/cli_ui.js +0 -0
  271. package/src/utils/inquirer.js +12 -0
  272. package/src/utils/paths.js +37 -0
  273. package/src/version.js +38 -0
  274. package/src/vfeTracker.js +220 -0
@@ -0,0 +1,1199 @@
1
+
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import crypto from 'crypto';
7
+ import * as gitBackend from './gitSnapshot.js';
8
+ import { unifiedDiff, looksBinary } from './textDiff.js';
9
+ import { causyneHome } from './utils/paths.js';
10
+ import { getActiveSessionId, SESSION_DIR } from './sessionStore.js';
11
+
12
+ /**
13
+ * Diagnostics, off by default.
14
+ *
15
+ * These were console.warn, which writes to a terminal the chat prompt is
16
+ * repainting a live region into — an uncontrolled second writer that leaves the
17
+ * prompt's row accounting describing a block that has moved. Housekeeping that
18
+ * skipped is not news to the person using Causyne; it is news to whoever is
19
+ * debugging it.
20
+ */
21
+ function debugLog(message) {
22
+ if (process.env.CAUSYNE_DEBUG !== '1') return;
23
+ process.stderr.write(`[causyne] ${message}\n`);
24
+ }
25
+
26
+
27
+ const STORE_DIR = path.join(causyneHome(), 'checkpoints');
28
+ const OBJECTS_DIR = path.join(STORE_DIR, 'objects');
29
+ const JOURNAL_FILE = path.join(STORE_DIR, 'index.jsonl');
30
+ const STAT_CACHE_FILE = path.join(STORE_DIR, 'stat-cache.json');
31
+
32
+ // The LEGACY single global conversation. Kept only so a journal entry written
33
+ // before sessions existed can still be restored.
34
+ const HISTORY_FILE = path.join(causyneHome(), 'memory', 'chat_history.json');
35
+
36
+ /**
37
+ * The conversation file undo must snapshot and restore.
38
+ *
39
+ * THE REGRESSION THIS FIXES. Undo is session-level BY DESIGN: restoring files
40
+ * while the conversation still claims the edits happened leaves the model
41
+ * reasoning about a disk state that no longer exists. That is the whole reason
42
+ * the history is captured at all.
43
+ *
44
+ * Chat history then moved out of one global `memory/chat_history.json` into per
45
+ * session files under `sessions/`, and `migrateLegacyHistory()` RENAMES the old
46
+ * file to `.migrated`. This module was never told. So `fs.existsSync(
47
+ * HISTORY_FILE)` became permanently false, `history` was recorded as null, and
48
+ * **the conversation rewind silently stopped happening** — undo restored the
49
+ * files and left the agent believing in edits that were gone.
50
+ *
51
+ * It survived the test suite because tests/checkpoints.test.js builds this path
52
+ * itself and writes the file before asserting. A test that creates the world it
53
+ * measures proves nothing about production.
54
+ */
55
+ function activeHistoryFile() {
56
+ try {
57
+ const id = getActiveSessionId();
58
+ if (id) {
59
+ const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
60
+ if (safe) return path.join(SESSION_DIR, `${safe}.json`);
61
+ }
62
+ } catch (e) { /* fall through to the legacy file */ }
63
+ return HISTORY_FILE;
64
+ }
65
+
66
+ // Retention. Generous enough that yesterday's mistake is still undoable, bounded
67
+ // enough that the store cannot grow without limit.
68
+ const RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
69
+ const MAX_STORE_BYTES = 512 * 1024 * 1024;
70
+ const MAX_JOURNAL_ENTRIES = 4000;
71
+
72
+ // A single file above this is recorded but not stored: snapshotting a 200 MB
73
+ // asset on every write would turn the safety net into a disk problem.
74
+ const MAX_FILE_BYTES = 16 * 1024 * 1024;
75
+
76
+ // Whole-workspace snapshot budgets. Exceeding either records an honest "skipped"
77
+ // rather than half a snapshot that would restore into an inconsistent tree.
78
+ const MAX_TREE_FILES = 5000;
79
+ const MAX_TREE_BYTES = 64 * 1024 * 1024;
80
+ const MAX_TREE_FILE_BYTES = 2 * 1024 * 1024;
81
+
82
+ // Directories never worth snapshotting: regenerable, and they dwarf the source.
83
+ const IGNORED_DIRECTORIES = new Set([
84
+ '.git', 'node_modules', 'dist', 'build', 'out', 'target',
85
+ '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache',
86
+ 'venv', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache',
87
+ 'vendor', 'Pods', '.gradle', '.idea', '.vscode', 'coverage',
88
+ '.causyne'
89
+ ]);
90
+
91
+ /** Which argument of each mutating tool names the file it is about to change. */
92
+ const MUTATING_TOOL_PATHS = {
93
+ fs_write: ['path'],
94
+ fs_edit: ['path'],
95
+ MultiEdit: ['file_path'],
96
+ fs_delete: ['path'],
97
+ // An overwrite move clobbers the destination, so both ends need a snapshot.
98
+ fs_move: ['source', 'destination']
99
+ };
100
+
101
+ export function isCheckpointingEnabled() {
102
+ return process.env.CAUSYNE_CHECKPOINTS !== '0';
103
+ }
104
+
105
+ function ensureStore() {
106
+ if (!fs.existsSync(OBJECTS_DIR)) {
107
+ fs.mkdirSync(OBJECTS_DIR, { recursive: true, mode: 0o700 });
108
+ }
109
+ }
110
+
111
+ function sha256(buffer) {
112
+ return crypto.createHash('sha256').update(buffer).digest('hex');
113
+ }
114
+
115
+ function objectPath(hash) {
116
+ return path.join(OBJECTS_DIR, hash.slice(0, 2), hash);
117
+ }
118
+
119
+ /**
120
+ * Store bytes under their own hash. Identical content is stored once, so a file
121
+ * edited ten times across three distinct states costs three blobs.
122
+ */
123
+ function writeObject(buffer) {
124
+ const hash = sha256(buffer);
125
+ const target = objectPath(hash);
126
+ if (fs.existsSync(target)) return hash; // already have it
127
+
128
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
129
+ // Write-then-rename: a crash mid-write must never leave a truncated blob
130
+ // sitting under a hash that claims to describe the full content.
131
+ const temp = `${target}.tmp_${process.pid}_${Date.now()}`;
132
+ fs.writeFileSync(temp, buffer, { mode: 0o600 });
133
+ fs.renameSync(temp, target);
134
+ return hash;
135
+ }
136
+
137
+ function readObject(hash) {
138
+ return fs.readFileSync(objectPath(hash));
139
+ }
140
+
141
+ /** Expand `~` exactly the way every fs tool does, then resolve. */
142
+ export function resolveToolPath(value) {
143
+ if (typeof value !== 'string' || value.length === 0) return null;
144
+ const expanded = value.replace(/^~\//, os.homedir() + '/');
145
+ return path.resolve(expanded);
146
+ }
147
+
148
+ function appendJournal(entry) {
149
+ fs.appendFileSync(JOURNAL_FILE, JSON.stringify(entry) + '\n', { mode: 0o600 });
150
+ }
151
+
152
+ export function readJournal() {
153
+ if (!fs.existsSync(JOURNAL_FILE)) return [];
154
+ const raw = fs.readFileSync(JOURNAL_FILE, 'utf8');
155
+ const entries = [];
156
+ for (const line of raw.split('\n')) {
157
+ if (!line.trim()) continue;
158
+ try { entries.push(JSON.parse(line)); } catch (e) { /* skip a torn line */ }
159
+ }
160
+ return entries;
161
+ }
162
+
163
+ function rewriteJournal(entries) {
164
+ const temp = `${JOURNAL_FILE}.tmp_${process.pid}`;
165
+ fs.writeFileSync(temp, entries.map(e => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''), { mode: 0o600 });
166
+ fs.renameSync(temp, JOURNAL_FILE);
167
+ }
168
+
169
+ function newId() {
170
+ return `ck_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Snapshotting
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Snapshot one file's current state.
179
+ * @returns {object|null} the `before` record, or null when the file is absent
180
+ * (which is itself meaningful: undo of a create means delete).
181
+ */
182
+ function snapshotFile(absolutePath) {
183
+ let stats;
184
+ try {
185
+ stats = fs.lstatSync(absolutePath);
186
+ } catch (e) {
187
+ return null; // does not exist yet
188
+ }
189
+
190
+ // A symlink is snapshotted as a link, never followed: following it would
191
+ // capture (and later overwrite) a file somewhere else entirely.
192
+ if (stats.isSymbolicLink()) {
193
+ return { kind: 'symlink', target: fs.readlinkSync(absolutePath), mode: stats.mode & 0o777 };
194
+ }
195
+ if (stats.isDirectory()) {
196
+ return { kind: 'directory', mode: stats.mode & 0o777 };
197
+ }
198
+ if (!stats.isFile()) {
199
+ return { kind: 'special' };
200
+ }
201
+ if (stats.size > MAX_FILE_BYTES) {
202
+ return { kind: 'file', skipped: 'too_large', size: stats.size, mode: stats.mode & 0o777 };
203
+ }
204
+
205
+ const buffer = fs.readFileSync(absolutePath);
206
+ return {
207
+ kind: 'file',
208
+ hash: writeObject(buffer),
209
+ size: stats.size,
210
+ mode: stats.mode & 0o777
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Capture the state of every file a mutating tool is about to touch.
216
+ *
217
+ * FAIL-OPEN BY CONTRACT: never throws, never blocks the tool call. A read-only
218
+ * or full disk degrades the safety net; it does not fail the task.
219
+ *
220
+ * @returns {string[]} ids of the entries written
221
+ */
222
+ export function captureBeforeMutation(toolName, args, meta = {}) {
223
+ if (!isCheckpointingEnabled()) return [];
224
+ const fields = MUTATING_TOOL_PATHS[toolName];
225
+ if (!fields || !args) return [];
226
+
227
+ const ids = [];
228
+ try {
229
+ ensureStore();
230
+ for (const field of fields) {
231
+ const absolutePath = resolveToolPath(args[field]);
232
+ if (!absolutePath) continue;
233
+
234
+ const before = snapshotFile(absolutePath);
235
+ const entry = {
236
+ id: newId(),
237
+ ts: Date.now(),
238
+ type: 'file',
239
+ taskId: meta.taskId || 'unknown',
240
+ step: meta.step == null ? null : meta.step,
241
+ tool: toolName,
242
+ path: absolutePath,
243
+ before
244
+ };
245
+ appendJournal(entry);
246
+ ids.push(entry.id);
247
+ }
248
+ } catch (e) {
249
+ debugLog(`checkpoint snapshot skipped: ${e.message}`);
250
+ }
251
+ return ids;
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Workspace tree snapshot — covers changes a per-file hook cannot see
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function loadStatCache() {
259
+ try {
260
+ return JSON.parse(fs.readFileSync(STAT_CACHE_FILE, 'utf8'));
261
+ } catch (e) {
262
+ return {};
263
+ }
264
+ }
265
+
266
+ function saveStatCache(cache) {
267
+ try {
268
+ const temp = `${STAT_CACHE_FILE}.tmp_${process.pid}`;
269
+ fs.writeFileSync(temp, JSON.stringify(cache), { mode: 0o600 });
270
+ fs.renameSync(temp, STAT_CACHE_FILE);
271
+ } catch (e) { /* the cache is an optimisation; losing it costs time, not correctness */ }
272
+ }
273
+
274
+ /**
275
+ * Walk a workspace root and record every file's content hash.
276
+ *
277
+ * The stat cache is what makes this affordable to repeat: a file whose mtime and
278
+ * size are unchanged since the last walk keeps its known hash and is never read
279
+ * again. That is git's own trick, and it is the difference between a snapshot
280
+ * that costs milliseconds and one that re-reads the whole tree every task.
281
+ *
282
+ * @returns {{manifest: object|null, skipped: string|null}}
283
+ */
284
+ function snapshotTree(root) {
285
+ const cache = loadStatCache();
286
+ const files = {};
287
+ let fileCount = 0;
288
+ let totalBytes = 0;
289
+ const stack = [root];
290
+
291
+ while (stack.length > 0) {
292
+ const dir = stack.pop();
293
+ let entries;
294
+ try {
295
+ entries = fs.readdirSync(dir, { withFileTypes: true });
296
+ } catch (e) {
297
+ continue; // unreadable directory: skip rather than abort the snapshot
298
+ }
299
+
300
+ for (const entry of entries) {
301
+ const full = path.join(dir, entry.name);
302
+
303
+ if (entry.isDirectory()) {
304
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
305
+ stack.push(full);
306
+ continue;
307
+ }
308
+ // Symlinks are recorded by target, never followed — following them can
309
+ // walk out of the workspace entirely, or loop.
310
+ if (entry.isSymbolicLink()) {
311
+ try { files[full] = { kind: 'symlink', target: fs.readlinkSync(full) }; } catch (e) {}
312
+ continue;
313
+ }
314
+ if (!entry.isFile()) continue;
315
+
316
+ let stats;
317
+ try { stats = fs.statSync(full); } catch (e) { continue; }
318
+ if (stats.size > MAX_TREE_FILE_BYTES) continue;
319
+
320
+ fileCount++;
321
+ totalBytes += stats.size;
322
+ if (fileCount > MAX_TREE_FILES) return { manifest: null, skipped: 'too_many_files' };
323
+ if (totalBytes > MAX_TREE_BYTES) return { manifest: null, skipped: 'workspace_too_large' };
324
+
325
+ const cached = cache[full];
326
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size
327
+ && fs.existsSync(objectPath(cached.hash))) {
328
+ files[full] = { kind: 'file', hash: cached.hash, size: stats.size, mode: stats.mode & 0o777 };
329
+ continue;
330
+ }
331
+
332
+ try {
333
+ const hash = writeObject(fs.readFileSync(full));
334
+ files[full] = { kind: 'file', hash, size: stats.size, mode: stats.mode & 0o777 };
335
+ cache[full] = { mtimeMs: stats.mtimeMs, size: stats.size, hash };
336
+ } catch (e) { /* a file that vanished mid-walk is simply not in the snapshot */ }
337
+ }
338
+ }
339
+
340
+ saveStatCache(cache);
341
+ return { manifest: { root, files, fileCount, totalBytes }, skipped: null };
342
+ }
343
+
344
+ /**
345
+ * Open a checkpoint group for a task: snapshot the conversation, and optionally
346
+ * the workspace tree. Called once per task, fail-open.
347
+ *
348
+ * @returns {string|null} the task id the group is filed under
349
+ */
350
+ /**
351
+ * Files that mark a directory as a PROJECT rather than a place.
352
+ *
353
+ * Ordered by how strongly each implies "this is the thing being worked on".
354
+ */
355
+ const PROJECT_MARKERS = [
356
+ '.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
357
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
358
+ 'requirements.txt', 'CMakeLists.txt', 'Makefile'
359
+ ];
360
+
361
+
362
+ function isSnapshotableRoot(dir) {
363
+ if (!dir) return false;
364
+ let resolved;
365
+ try {
366
+ resolved = path.resolve(dir);
367
+ } catch (e) {
368
+ return false;
369
+ }
370
+
371
+ const home = os.homedir();
372
+ const parsed = path.parse(resolved);
373
+
374
+ // The filesystem root, and the user's home itself.
375
+ if (resolved === parsed.root) return false;
376
+ if (home && path.resolve(home) === resolved) return false;
377
+
378
+
379
+ // lands in one of these is not in a project, it is lost.
380
+ const forbidden = ['/Users', '/home', '/Users/Shared', '/var', '/tmp', '/etc', '/opt', '/usr'];
381
+ if (forbidden.includes(resolved)) return false;
382
+
383
+ // Directly inside a system root with no project marker is not a workspace.
384
+ return true;
385
+ }
386
+
387
+ /**
388
+ * The project `startDir` belongs to, or null when it belongs to none.
389
+ *
390
+ * Walks upward for a project marker, stopping at the home directory — a marker
391
+ * found above home would mean snapshotting somebody's whole account.
392
+ */
393
+ export function resolveProjectRoot(startDir) {
394
+ let current;
395
+ try {
396
+ current = path.resolve(startDir || process.cwd());
397
+ } catch (e) {
398
+ return null;
399
+ }
400
+
401
+ const home = os.homedir() ? path.resolve(os.homedir()) : null;
402
+ const stop = path.parse(current).root;
403
+
404
+ const start = current;
405
+ let nearestMarker = null;
406
+
407
+ for (let depth = 0; depth < 40; depth++) {
408
+ if (!isSnapshotableRoot(current)) break;
409
+
410
+ // A repository root wins outright, even from further up.
411
+ //
412
+ // Stopping at the FIRST marker found sounds right and is not: this project
413
+ // has package.json in causyne-agent/ and .git one level above it, so the
414
+ // nearest marker would scope the checkpoint to causyne-agent — which the git
415
+ // backend then declines, because its root is not the repository root. The
416
+ // native store would take it instead and immediately hit MAX_TREE_FILES on
417
+ // node_modules. The repository root is both the true project boundary AND
418
+ // the only root where git can apply the user's own .gitignore, which is what
419
+ // keeps a snapshot to the files that actually matter.
420
+ try {
421
+ if (fs.existsSync(path.join(current, '.git'))) return current;
422
+ } catch (e) {}
423
+
424
+ if (!nearestMarker) {
425
+ for (const marker of PROJECT_MARKERS) {
426
+ if (marker === '.git') continue;
427
+ try {
428
+ if (fs.existsSync(path.join(current, marker))) { nearestMarker = current; break; }
429
+ } catch (e) {}
430
+ }
431
+ }
432
+
433
+ const parent = path.dirname(current);
434
+ if (parent === current || current === stop) break;
435
+ if (home && parent === home) break;
436
+ current = parent;
437
+ }
438
+
439
+ // No repository anywhere above: fall back to the nearest ordinary project.
440
+ return nearestMarker && isSnapshotableRoot(nearestMarker) ? nearestMarker : null;
441
+ }
442
+
443
+ /**
444
+ * Capture `root` with the git backend, or return null to use the native store.
445
+ *
446
+ * Every failure path returns null rather than throwing: a checkpoint is
447
+ * best-effort and must never be the reason a task fails to start.
448
+ */
449
+ function gitRootFor(root) {
450
+ try {
451
+ if (!gitBackend.isGitAvailable()) return null;
452
+ const worktree = gitBackend.resolveWorktree(root);
453
+ // Only capture when the root IS the repository root. A subdirectory would
454
+ // snapshot its whole parent project, which is not what the caller asked to
455
+ // protect and could be enormous.
456
+ if (!worktree || path.resolve(worktree) !== path.resolve(root)) return null;
457
+ return worktree;
458
+ } catch (e) {
459
+ debugLog(`git checkpoint unavailable for ${root}: ${e.message}`);
460
+ return null;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * What a git-backed tree would restore, and — when asked — restoring it.
466
+ *
467
+ * Returns null on any git failure so the caller falls back to the native path;
468
+ * reporting "nothing changed" because git errored would be a lie with a
469
+ * rollback button attached to it.
470
+ */
471
+ function gitTreeChanges(tree, undoTaskId, dryRun, applyFull) {
472
+ try {
473
+ const changes = gitBackend.previewRestore(tree.root, tree.gitTree);
474
+ const driftFiles = changes.map((c) => c.path);
475
+
476
+ if (!applyFull || dryRun) {
477
+ return {
478
+ driftFiles,
479
+ results: changes.map((c) => ({
480
+ path: path.join(tree.root, c.path),
481
+ action: c.status === 'deleted' ? 'removed' : 'restored',
482
+ ok: true
483
+ }))
484
+ };
485
+ }
486
+
487
+ gitBackend.restoreTree(tree.root, tree.gitTree);
488
+ return {
489
+ driftFiles,
490
+ results: changes.map((c) => ({
491
+ path: path.join(tree.root, c.path),
492
+ action: c.status === 'deleted' ? 'removed' : 'restored',
493
+ ok: true
494
+ }))
495
+ };
496
+ } catch (e) {
497
+ debugLog(`git restore unavailable for ${tree.root}: ${e.message}`);
498
+ return null;
499
+ }
500
+ }
501
+
502
+ export async function beginTask(meta = {}) {
503
+ if (!isCheckpointingEnabled()) return null;
504
+ const taskId = meta.taskId || 'unknown';
505
+
506
+ try {
507
+ ensureStore();
508
+
509
+ // The conversation. Snapshotting the FILE rather than a message index,
510
+ // because boundHistory() trims chat_history.json to its newest 120 messages
511
+ // on every save — an index into that array does not survive the trimming.
512
+ let history = null;
513
+ const historyFile = activeHistoryFile();
514
+ if (fs.existsSync(historyFile)) {
515
+ const buffer = fs.readFileSync(historyFile);
516
+ // The PATH is recorded, not just the bytes. The active session can change
517
+ // between the task and the undo, and restoring a snapshot into whichever
518
+ // conversation happens to be current would corrupt a different one.
519
+ history = { hash: writeObject(buffer), size: buffer.length, file: historyFile };
520
+ }
521
+
522
+ appendJournal({
523
+ id: newId(),
524
+ ts: Date.now(),
525
+ type: 'task_start',
526
+ taskId,
527
+ prompt: typeof meta.prompt === 'string' ? meta.prompt.slice(0, 200) : null,
528
+ history
529
+ });
530
+
531
+ // The workspace tree, which also covers files changed by sysRun and by any
532
+ // other tool that does not announce its paths.
533
+ //
534
+ // De-duplicated before the slice: only three roots are snapshotted, so a
535
+ // repeated path spends one of those three slots twice and doubles the
536
+ // storage for a directory already covered. The caller's list is a
537
+ // concatenation of the working directory and the permitted scope, which
538
+ // overlap whenever the user is working inside their own workspace — that is
539
+ // to say, normally.
540
+ // A root that is not a project is not worth snapshotting, and trying is
541
+ // what made undo useless: $HOME blew past MAX_TREE_FILES and the fallbacks
542
+ // captured directories nobody edits. Filtered HERE as well as at the caller,
543
+ // because a checkpoint store that quietly fills with junk is worse than one
544
+ // that honestly captures nothing.
545
+ const roots = (meta.workspaceRoots || [])
546
+ .filter((root, index, all) => root && all.indexOf(root) === index)
547
+ .filter((root) => isSnapshotableRoot(root))
548
+ .slice(0, 3);
549
+
550
+ for (const root of roots) {
551
+ if (!fs.existsSync(root)) continue;
552
+
553
+ // Prefer git where the root is a repository and git is installed.
554
+ //
555
+ // The file-copy path below copies bytes, so it has to cap what it will
556
+ // look at — MAX_TREE_FILES is 5000, and a real home directory exceeds it
557
+ // and is recorded as `too_many_files`, covered by no rollback at all. Git
558
+ // has no such ceiling: it reuses the project's own object database, so a
559
+ // checkpoint is one `write-tree` rather than a walk-and-hash of every
560
+ // file. The shadow repository lives in ~/.causyne and the user's own .git
561
+ // is never written to.
562
+ //
563
+ // Fail-open in both directions: no git binary, or a root that is not a
564
+ // repository, or any git error at all, and this silently uses the native
565
+ // store exactly as before.
566
+ // The decision is made SYNCHRONOUSLY, and only a real git capture awaits.
567
+ //
568
+ // Awaiting unconditionally deferred the native path too: an `async`
569
+ // function runs synchronously only until its first await, so every caller
570
+ // that does not itself await — which is most of them — stopped seeing the
571
+ // journal entries this function had always written before returning.
572
+ const gitWorktree = gitRootFor(root);
573
+ let gitTree = null;
574
+ if (gitWorktree) {
575
+ // Async here and nowhere else: this runs at the start of every task,
576
+ // and a synchronous capture can stall the foreground agent loop.
577
+ try {
578
+ gitTree = await gitBackend.captureTreeAsync(gitWorktree);
579
+ } catch (e) {
580
+ debugLog(`git capture failed for ${root}: ${e.message}`);
581
+ }
582
+ }
583
+ if (gitTree) {
584
+ appendJournal({
585
+ id: newId(),
586
+ ts: Date.now(),
587
+ type: 'tree',
588
+ taskId,
589
+ root,
590
+ skipped: null,
591
+ backend: 'git',
592
+ gitTree,
593
+ manifestHash: null,
594
+ fileCount: 0,
595
+ totalBytes: 0
596
+ });
597
+ continue;
598
+ }
599
+
600
+ const { manifest, skipped } = snapshotTree(root);
601
+ appendJournal({
602
+ id: newId(),
603
+ ts: Date.now(),
604
+ type: 'tree',
605
+ taskId,
606
+ root,
607
+ skipped,
608
+ manifestHash: manifest ? writeObject(Buffer.from(JSON.stringify(manifest), 'utf8')) : null,
609
+ fileCount: manifest ? manifest.fileCount : 0,
610
+ totalBytes: manifest ? manifest.totalBytes : 0
611
+ });
612
+ }
613
+
614
+ return taskId;
615
+ } catch (e) {
616
+ debugLog(`checkpoint task snapshot skipped: ${e.message}`);
617
+ return null;
618
+ }
619
+ }
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // Restore
623
+ // ---------------------------------------------------------------------------
624
+
625
+ /**
626
+ * Refuse to write through anything that is not a plain file.
627
+ *
628
+ * A restore writes attacker-influenced paths. If the target has been replaced by
629
+ * a symlink between snapshot and restore, following it writes wherever the link
630
+ * points; a hardlink means the write lands in a second visible location too.
631
+ * Both are refused rather than followed.
632
+ */
633
+ function assertSafeTarget(absolutePath) {
634
+ if (!path.isAbsolute(absolutePath)) {
635
+ throw new Error(`refusing a non-absolute path: ${absolutePath}`);
636
+ }
637
+ if (absolutePath !== path.resolve(absolutePath)) {
638
+ throw new Error(`refusing an unnormalised path: ${absolutePath}`);
639
+ }
640
+
641
+ let stats;
642
+ try {
643
+ stats = fs.lstatSync(absolutePath);
644
+ } catch (e) {
645
+ return; // absent: nothing to be tricked by
646
+ }
647
+ if (stats.isSymbolicLink()) {
648
+ throw new Error(`refusing to write through a symlink: ${absolutePath}`);
649
+ }
650
+ if (stats.isFile() && stats.nlink > 1) {
651
+ throw new Error(`refusing to write through a hardlink (${stats.nlink} links): ${absolutePath}`);
652
+ }
653
+ }
654
+
655
+ function restoreFileState(absolutePath, before) {
656
+ assertSafeTarget(absolutePath);
657
+
658
+ // The file did not exist when we snapshotted, so undoing means removing it.
659
+ if (before === null) {
660
+ if (fs.existsSync(absolutePath)) fs.rmSync(absolutePath, { recursive: true, force: true });
661
+ return 'removed';
662
+ }
663
+ if (before.kind === 'symlink') {
664
+ // lstat, not exists(): a dangling symlink fails existsSync but still
665
+ // occupies the name and would make symlinkSync throw EEXIST.
666
+ try { fs.lstatSync(absolutePath); fs.unlinkSync(absolutePath); } catch (e) { /* nothing there */ }
667
+ fs.symlinkSync(before.target, absolutePath);
668
+ return 'restored symlink';
669
+ }
670
+ if (before.kind === 'directory') {
671
+ fs.mkdirSync(absolutePath, { recursive: true });
672
+ return 'restored directory';
673
+ }
674
+ if (before.kind !== 'file') return 'skipped (not a regular file)';
675
+ if (before.skipped) return `skipped (${before.skipped})`;
676
+
677
+ const buffer = readObject(before.hash);
678
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
679
+ // Atomic swap, matching how fs_write itself lands content.
680
+ const temp = `${absolutePath}.causyne_restore_${process.pid}_${Date.now()}`;
681
+ fs.writeFileSync(temp, buffer, { mode: before.mode || 0o644 });
682
+ fs.renameSync(temp, absolutePath);
683
+ return 'restored';
684
+ }
685
+
686
+ /**
687
+ * Compare the workspace as it is NOW against the manifest taken at task start.
688
+ *
689
+ * This is what covers files changed by `sysRun` and friends: a shell command
690
+ * announces no paths, so the per-file hook cannot see it, but the tree snapshot
691
+ * did record the before-state of everything under the workspace root.
692
+ *
693
+ * @returns {{modified: string[], created: string[], deleted: string[]}|null}
694
+ */
695
+ export function diffTree(treeEntry) {
696
+ if (!treeEntry || !treeEntry.manifestHash || treeEntry.skipped) return null;
697
+
698
+ let manifest;
699
+ try {
700
+ manifest = JSON.parse(readObject(treeEntry.manifestHash).toString('utf8'));
701
+ } catch (e) {
702
+ return null;
703
+ }
704
+
705
+ const modified = [];
706
+ const deleted = [];
707
+ const known = new Set(Object.keys(manifest.files || {}));
708
+
709
+ for (const [absolutePath, record] of Object.entries(manifest.files || {})) {
710
+ if (record.kind !== 'file') continue;
711
+ let stats;
712
+ try {
713
+ stats = fs.lstatSync(absolutePath);
714
+ } catch (e) {
715
+ deleted.push(absolutePath);
716
+ continue;
717
+ }
718
+ if (stats.isSymbolicLink() || !stats.isFile()) continue;
719
+ if (stats.size !== record.size) { modified.push(absolutePath); continue; }
720
+ try {
721
+ if (sha256(fs.readFileSync(absolutePath)) !== record.hash) modified.push(absolutePath);
722
+ } catch (e) { /* unreadable now: leave it alone rather than guess */ }
723
+ }
724
+
725
+ // Files that exist now but were not in the snapshot were created during the task.
726
+ const created = [];
727
+ const { manifest: current } = snapshotTree(manifest.root);
728
+ if (current) {
729
+ for (const absolutePath of Object.keys(current.files)) {
730
+ if (!known.has(absolutePath)) created.push(absolutePath);
731
+ }
732
+ }
733
+
734
+ return { modified, created, deleted, manifest };
735
+ }
736
+
737
+ /**
738
+ * Restore every file the tree snapshot covers that has drifted since task start.
739
+ * Deliberately NOT the default: it reverts changes the agent did not make too
740
+ * (anything you edited yourself in another window while the task ran), so it is
741
+ * only reached through an explicit `--full`.
742
+ */
743
+ function restoreFromTree(treeEntry, undoTaskId, dryRun) {
744
+ const diff = diffTree(treeEntry);
745
+ if (!diff) return [];
746
+
747
+ const results = [];
748
+ const apply = (absolutePath, before, label) => {
749
+ if (dryRun) {
750
+ results.push({ path: absolutePath, action: `would ${label}` });
751
+ return;
752
+ }
753
+ try {
754
+ const current = snapshotFile(absolutePath);
755
+ appendJournal({
756
+ id: newId(), ts: Date.now(), type: 'file', taskId: undoTaskId,
757
+ step: null, tool: 'undo-tree', path: absolutePath, before: current
758
+ });
759
+ results.push({ path: absolutePath, action: restoreFileState(absolutePath, before) });
760
+ } catch (e) {
761
+ results.push({ path: absolutePath, action: `FAILED: ${e.message}` });
762
+ }
763
+ };
764
+
765
+ for (const absolutePath of [...diff.modified, ...diff.deleted]) {
766
+ apply(absolutePath, diff.manifest.files[absolutePath], 'restore');
767
+ }
768
+ for (const absolutePath of diff.created) {
769
+ apply(absolutePath, null, 'remove');
770
+ }
771
+ return results;
772
+ }
773
+
774
+
775
+ export function isUndoable(group) {
776
+ if (!group) return false;
777
+ return group.fileEntries.length > 0 || group.trees.some((tree) => !tree.skipped);
778
+ }
779
+
780
+ /** Group the journal into per-task groups, newest first. */
781
+ export function listGroups(limit = 20) {
782
+ const entries = readJournal();
783
+ const byTask = new Map();
784
+
785
+ for (const entry of entries) {
786
+ if (!byTask.has(entry.taskId)) {
787
+ byTask.set(entry.taskId, { taskId: entry.taskId, ts: entry.ts, entries: [], prompt: null });
788
+ }
789
+ const group = byTask.get(entry.taskId);
790
+ group.ts = Math.max(group.ts, entry.ts);
791
+ if (entry.type === 'task_start' && entry.prompt) group.prompt = entry.prompt;
792
+ group.entries.push(entry);
793
+ }
794
+
795
+ return [...byTask.values()]
796
+ .sort((a, b) => b.ts - a.ts)
797
+ .slice(0, limit)
798
+ .map(group => ({
799
+ ...group,
800
+ fileEntries: group.entries.filter(e => e.type === 'file'),
801
+ hasHistory: group.entries.some(e => e.type === 'task_start' && e.history),
802
+ trees: group.entries.filter(e => e.type === 'tree')
803
+ }));
804
+ }
805
+
806
+ /**
807
+ * Roll a task back: its files AND the conversation, together.
808
+ *
809
+ * The restore itself is checkpointed first, under a fresh `undo_<ts>` task, so
810
+ * an undo can itself be undone.
811
+ */
812
+ export function undoTask(taskId, options = {}) {
813
+ // Fail SAFE, not dangerous.
814
+ //
815
+ // This was `=== true`, so an options object that simply lacked the flag —
816
+ // a malformed frame, a truncated payload, a new caller that forgot it —
817
+ // performed a REAL rollback over the user's files. The default for an
818
+ // operation that overwrites work must be the one that writes nothing; a
819
+ // caller that means to write now has to say so.
820
+ const dryRun = options.dryRun !== false;
821
+ const groups = listGroups(1000);
822
+ const group = groups.find(g => g.taskId === taskId);
823
+ if (!group) return { ok: false, error: `no checkpoint group for task "${taskId}"` };
824
+
825
+ const results = [];
826
+ const undoTaskId = `undo_${Date.now().toString(36)}`;
827
+
828
+ // A file touched three times in one task must end up back at the state it had
829
+ // before the FIRST of those writes — so for each path keep the OLDEST
830
+ // snapshot, walking the group oldest-first.
831
+ const earliest = new Map();
832
+ for (const entry of [...group.fileEntries].sort((a, b) => a.ts - b.ts)) {
833
+ if (!earliest.has(entry.path)) earliest.set(entry.path, entry);
834
+ }
835
+
836
+ const wantsFiles = options.scope !== 'conversation';
837
+ const wantsHistory = options.scope !== 'code';
838
+
839
+ for (const entry of (wantsFiles ? earliest.values() : [])) {
840
+ if (dryRun) {
841
+ results.push({ path: entry.path, action: entry.before === null ? 'would remove' : 'would restore' });
842
+ continue;
843
+ }
844
+ try {
845
+ // Checkpoint the CURRENT state before overwriting it, so this is reversible.
846
+ const current = snapshotFile(entry.path);
847
+ appendJournal({
848
+ id: newId(), ts: Date.now(), type: 'file', taskId: undoTaskId,
849
+ step: null, tool: 'undo', path: entry.path, before: current
850
+ });
851
+
852
+ const action = restoreFileState(entry.path, entry.before);
853
+ results.push({ path: entry.path, action });
854
+ } catch (e) {
855
+ results.push({ path: entry.path, action: `FAILED: ${e.message}` });
856
+ }
857
+ }
858
+
859
+ // The conversation, rewound to the state it had when the task began.
860
+ let historyRestored = false;
861
+ const start = group.entries.find(e => e.type === 'task_start' && e.history);
862
+
863
+ // The two halves of a rewind are independently selectable.
864
+ //
865
+ // undoTask has always restored files AND rewound the conversation together,
866
+ // and the split existed internally without ever reaching the user. A rewind
867
+ // that only rewinds the chat is genuinely useful — it is how you re-ask a
868
+ // question without throwing away work the agent got right — and one that only
869
+ // touches files keeps the reasoning while discarding the edits. An absent
870
+ // scope means BOTH, so every existing caller is unchanged.
871
+ if (start && !dryRun && wantsHistory) {
872
+ // Restore into the file the snapshot was TAKEN from. Entries written before
873
+ // sessions existed carry no path and mean the legacy global history.
874
+ const target = start.history.file || HISTORY_FILE;
875
+ try {
876
+ // A journal is local and 0600, but it is still data deciding a write
877
+ // target. Confine the restore to Causyne's own state root so a tampered or
878
+ // hand-edited entry cannot direct it anywhere else.
879
+ const root = causyneHome();
880
+ const resolved = path.resolve(target);
881
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
882
+ throw new Error('conversation snapshot points outside the Causyne state directory');
883
+ }
884
+
885
+ if (fs.existsSync(resolved)) {
886
+ const buffer = fs.readFileSync(resolved);
887
+ appendJournal({
888
+ id: newId(), ts: Date.now(), type: 'task_start', taskId: undoTaskId,
889
+ prompt: 'undo restore point',
890
+ history: { hash: writeObject(buffer), size: buffer.length, file: resolved }
891
+ });
892
+ }
893
+ assertSafeTarget(resolved);
894
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
895
+ fs.writeFileSync(resolved, readObject(start.history.hash), { mode: 0o600 });
896
+ historyRestored = true;
897
+ } catch (e) {
898
+ results.push({ path: target, action: `FAILED: ${e.message}` });
899
+ }
900
+ }
901
+
902
+ // Workspace drift the per-file hook could not see — a shell command announces
903
+ // no paths. Reported by default, restored only when explicitly asked, because
904
+ // it also covers edits the user made by hand while the task ran.
905
+ const drift = [];
906
+ const treeResults = [];
907
+ for (const tree of (wantsFiles ? group.trees : [])) {
908
+ if (tree.skipped) continue;
909
+
910
+ // A git-backed tree answers both questions from the same pair of SHAs, so
911
+ // the preview and the restore can never disagree about what would happen.
912
+ if (tree.backend === 'git' && tree.gitTree) {
913
+ const gitChanges = gitTreeChanges(tree, undoTaskId, dryRun, options.full === true);
914
+ if (gitChanges) {
915
+ if (options.full) treeResults.push(...gitChanges.results);
916
+ else if (gitChanges.driftFiles.length > 0) {
917
+ drift.push({ root: tree.root, files: gitChanges.driftFiles });
918
+ }
919
+ continue;
920
+ }
921
+ // Any git failure falls through to the native path below rather than
922
+ // silently reporting that nothing changed.
923
+ }
924
+
925
+ if (options.full) {
926
+ treeResults.push(...restoreFromTree(tree, undoTaskId, dryRun));
927
+ continue;
928
+ }
929
+ const diff = diffTree(tree);
930
+ if (!diff) continue;
931
+ const untracked = [...diff.modified, ...diff.created, ...diff.deleted]
932
+ .filter(p => !earliest.has(p));
933
+ if (untracked.length > 0) drift.push({ root: tree.root, files: untracked });
934
+ }
935
+
936
+ return {
937
+ ok: true,
938
+ taskId,
939
+ dryRun,
940
+ full: options.full === true,
941
+ results: [...results, ...treeResults],
942
+ historyRestored,
943
+ historyAvailable: Boolean(start),
944
+ scope: options.scope || 'both',
945
+ drift,
946
+ skippedTrees: group.trees.filter(t => t.skipped).map(t => ({ root: t.root, reason: t.skipped }))
947
+ };
948
+ }
949
+
950
+ /** Undo the most recent task that actually changed a file. */
951
+ export function undoLast(options = {}) {
952
+ // A task is undoable if it captured file entries OR a workspace tree.
953
+ //
954
+ // This used to require `fileEntries.length > 0`, which quietly discarded the
955
+ // entire tree mechanism. The per-file hook only fires for the fs_* tools;
956
+ // a task that did its work through the shell — `npm install`, `git checkout`,
957
+ // a build script, `mv`, `rm` — announces no paths and produces ZERO file
958
+ // entries. beginTask() snapshots the whole workspace tree precisely to cover
959
+ // that case, and undoTask() already knows how to diff and restore it.
960
+ //
961
+ // So the store held hundreds of tree snapshots, cost megabytes to keep, and
962
+ // `/undo` answered "no file changes have been checkpointed yet" — which was
963
+ // not merely unhelpful, it was untrue.
964
+ const candidates = listGroups(200).filter((group) =>
965
+ !group.taskId.startsWith('undo_') && isUndoable(group));
966
+
967
+ if (candidates.length === 0) {
968
+ return { ok: false, error: 'nothing has been checkpointed yet — no task has run in this workspace' };
969
+ }
970
+ return undoTask(candidates[0].taskId, options);
971
+ }
972
+
973
+ /** Undo just the single most recent file mutation. */
974
+ export function undoStep(options = {}) {
975
+ // Fail SAFE, not dangerous.
976
+ //
977
+ // This was `=== true`, so an options object that simply lacked the flag —
978
+ // a malformed frame, a truncated payload, a new caller that forgot it —
979
+ // performed a REAL rollback over the user's files. The default for an
980
+ // operation that overwrites work must be the one that writes nothing; a
981
+ // caller that means to write now has to say so.
982
+ const dryRun = options.dryRun !== false;
983
+ const entries = readJournal().filter(e => e.type === 'file' && e.tool !== 'undo');
984
+ if (entries.length === 0) return { ok: false, error: 'no file mutation has been checkpointed yet' };
985
+
986
+ const entry = entries[entries.length - 1];
987
+ if (dryRun) {
988
+ return { ok: true, dryRun, results: [{ path: entry.path, action: entry.before === null ? 'would remove' : 'would restore' }] };
989
+ }
990
+
991
+ try {
992
+ const current = snapshotFile(entry.path);
993
+ appendJournal({
994
+ id: newId(), ts: Date.now(), type: 'file', taskId: `undo_${Date.now().toString(36)}`,
995
+ step: null, tool: 'undo', path: entry.path, before: current
996
+ });
997
+ const action = restoreFileState(entry.path, entry.before);
998
+ return { ok: true, dryRun: false, results: [{ path: entry.path, action }] };
999
+ } catch (e) {
1000
+ return { ok: false, error: e.message };
1001
+ }
1002
+ }
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+ // Retention
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ function directorySize(dir) {
1009
+ let total = 0;
1010
+ const stack = [dir];
1011
+ while (stack.length > 0) {
1012
+ const current = stack.pop();
1013
+ let entries;
1014
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { continue; }
1015
+ for (const entry of entries) {
1016
+ const full = path.join(current, entry.name);
1017
+ if (entry.isDirectory()) { stack.push(full); continue; }
1018
+ try { total += fs.statSync(full).size; } catch (e) {}
1019
+ }
1020
+ }
1021
+ return total;
1022
+ }
1023
+
1024
+ /**
1025
+ * Drop journal entries past the retention window (or past the entry ceiling),
1026
+ * then delete every blob no surviving entry still references.
1027
+ */
1028
+ export function pruneStore() {
1029
+ try {
1030
+ if (!fs.existsSync(JOURNAL_FILE)) return { removedEntries: 0, removedObjects: 0 };
1031
+
1032
+ let entries = readJournal();
1033
+ const before = entries.length;
1034
+ const cutoff = Date.now() - RETENTION_MS;
1035
+ const overSize = directorySize(OBJECTS_DIR) > MAX_STORE_BYTES;
1036
+
1037
+ entries = entries.filter(e => e.ts >= cutoff);
1038
+ if (entries.length > MAX_JOURNAL_ENTRIES || overSize) {
1039
+ const keep = overSize ? Math.floor(MAX_JOURNAL_ENTRIES / 2) : MAX_JOURNAL_ENTRIES;
1040
+ entries = entries.slice(-keep);
1041
+ }
1042
+ if (entries.length !== before) rewriteJournal(entries);
1043
+
1044
+ // Mark and sweep: anything no surviving entry references is unreachable.
1045
+ const live = new Set();
1046
+ for (const entry of entries) {
1047
+ if (entry.before && entry.before.hash) live.add(entry.before.hash);
1048
+ if (entry.history && entry.history.hash) live.add(entry.history.hash);
1049
+ if (entry.manifestHash) {
1050
+ live.add(entry.manifestHash);
1051
+ try {
1052
+ const manifest = JSON.parse(readObject(entry.manifestHash).toString('utf8'));
1053
+ for (const record of Object.values(manifest.files || {})) {
1054
+ if (record && record.hash) live.add(record.hash);
1055
+ }
1056
+ } catch (e) { /* an unreadable manifest keeps only itself alive */ }
1057
+ }
1058
+ }
1059
+
1060
+ let removedObjects = 0;
1061
+ let shards;
1062
+ try { shards = fs.readdirSync(OBJECTS_DIR); } catch (e) { shards = []; }
1063
+ for (const shard of shards) {
1064
+ const shardDir = path.join(OBJECTS_DIR, shard);
1065
+ let objects;
1066
+ try { objects = fs.readdirSync(shardDir); } catch (e) { continue; }
1067
+ for (const name of objects) {
1068
+ if (live.has(name)) continue;
1069
+ try { fs.unlinkSync(path.join(shardDir, name)); removedObjects++; } catch (e) {}
1070
+ }
1071
+ try { if (fs.readdirSync(shardDir).length === 0) fs.rmdirSync(shardDir); } catch (e) {}
1072
+ }
1073
+
1074
+ return { removedEntries: before - entries.length, removedObjects };
1075
+ } catch (e) {
1076
+ debugLog(`checkpoint prune skipped: ${e.message}`);
1077
+ return { removedEntries: 0, removedObjects: 0 };
1078
+ }
1079
+ }
1080
+
1081
+ export function getStoreDir() {
1082
+ return STORE_DIR;
1083
+ }
1084
+
1085
+ export function getStoreStats() {
1086
+ return {
1087
+ dir: STORE_DIR,
1088
+ entries: readJournal().length,
1089
+ bytes: fs.existsSync(OBJECTS_DIR) ? directorySize(OBJECTS_DIR) : 0
1090
+ };
1091
+ }
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // Task diff — what REVIEW shows
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ /**
1098
+ * The per-file changes a task made, as a real diff.
1099
+ *
1100
+ * This is what REVIEW opens. Before it existed, REVIEW raised the ROLLBACK
1101
+ * confirmation dialog — so "let me see what happened" and "undo this" were the
1102
+ * same button, and the only way to find out what an agent had done was to be
1103
+ * asked whether you wanted to destroy it. Read-only, and it never restores.
1104
+ *
1105
+ * Both backends answer: git supplies patches directly, and the native store
1106
+ * reconstructs them from the `before` blob it already holds, so the feature
1107
+ * degrades in fidelity rather than disappearing on a machine without git.
1108
+ */
1109
+ export function buildTaskDiff(taskId) {
1110
+ const group = listGroups(1000).find((g) => g.taskId === taskId);
1111
+ if (!group) {
1112
+ return { ok: false, taskId: taskId || null, error: 'no checkpoint for that task', files: [] };
1113
+ }
1114
+
1115
+ const files = [];
1116
+
1117
+ // 1. Git-backed trees: git computes the patch against the checkpoint tree.
1118
+ for (const tree of group.trees || []) {
1119
+ if (tree.backend !== 'git' || !tree.gitTree) continue;
1120
+ try {
1121
+ for (const change of gitBackend.previewRestore(tree.root, tree.gitTree)) {
1122
+ files.push({
1123
+ path: change.path,
1124
+ // The preview describes the RESTORE, which runs backwards. Invert it
1125
+ // so the user reads what the TASK did, which is what they asked.
1126
+ status: change.status === 'deleted' ? 'created'
1127
+ : change.status === 'added' ? 'deleted'
1128
+ : change.status,
1129
+ additions: change.deletions,
1130
+ deletions: change.additions,
1131
+ patch: change.patch || '',
1132
+ truncated: false
1133
+ });
1134
+ }
1135
+ } catch (e) {
1136
+ debugLog(`task diff unavailable for ${tree.root}: ${e.message}`);
1137
+ }
1138
+ }
1139
+
1140
+ // 2. Native file entries, for anything git did not already describe.
1141
+ const seen = new Set(files.map((f) => f.path));
1142
+ for (const entry of group.fileEntries || []) {
1143
+ // The journal stores absolute paths; a diff header showing
1144
+
1145
+ // the working directory where that makes sense, basename otherwise.
1146
+ const fromCwd = path.relative(process.cwd(), entry.path);
1147
+ const relative = (!fromCwd || fromCwd.startsWith('..'))
1148
+ ? path.basename(entry.path)
1149
+ : fromCwd;
1150
+ if (seen.has(relative)) continue;
1151
+ seen.add(relative);
1152
+
1153
+ const beforeBuffer = entry.before ? readObject(entry.before.hash) : null;
1154
+ let afterBuffer = null;
1155
+ try {
1156
+ if (fs.existsSync(entry.path)) afterBuffer = fs.readFileSync(entry.path);
1157
+ } catch (e) {}
1158
+
1159
+ const status = !beforeBuffer ? 'created' : !afterBuffer ? 'deleted' : 'modified';
1160
+
1161
+ if (looksBinary(beforeBuffer) || looksBinary(afterBuffer)) {
1162
+ files.push({
1163
+ path: relative, status, additions: 0, deletions: 0, patch: '', truncated: true
1164
+ });
1165
+ continue;
1166
+ }
1167
+
1168
+ const diff = unifiedDiff(
1169
+ beforeBuffer ? beforeBuffer.toString('utf8') : '',
1170
+ afterBuffer ? afterBuffer.toString('utf8') : '',
1171
+ relative
1172
+ );
1173
+ files.push({
1174
+ path: relative,
1175
+ status,
1176
+ additions: diff.additions,
1177
+ deletions: diff.deletions,
1178
+ patch: diff.patch,
1179
+ truncated: diff.truncated
1180
+ });
1181
+ }
1182
+
1183
+ const totals = files.reduce(
1184
+ (acc, f) => ({ additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }),
1185
+ { additions: 0, deletions: 0 }
1186
+ );
1187
+
1188
+ return {
1189
+ ok: true,
1190
+ taskId: group.taskId,
1191
+ prompt: group.prompt || null,
1192
+ fileCount: files.length,
1193
+ additions: totals.additions,
1194
+ deletions: totals.deletions,
1195
+ // Capped: a refactor can touch hundreds of files and this frame crosses a
1196
+
1197
+ files: files.slice(0, 50)
1198
+ };
1199
+ }