whydid 1.0.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 (84) hide show
  1. package/README.md +232 -0
  2. package/dashboard/dist/android-chrome-192x192.png +0 -0
  3. package/dashboard/dist/android-chrome-512x512.png +0 -0
  4. package/dashboard/dist/apple-touch-icon.png +0 -0
  5. package/dashboard/dist/assets/index-DImKqodm.js +69 -0
  6. package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
  7. package/dashboard/dist/favicon-16x16.png +0 -0
  8. package/dashboard/dist/favicon-32x32.png +0 -0
  9. package/dashboard/dist/favicon.ico +0 -0
  10. package/dashboard/dist/index.html +24 -0
  11. package/dashboard/dist/logo-mark.svg +8 -0
  12. package/dashboard/dist/site.webmanifest +11 -0
  13. package/dist/adapters/claude-code.js +168 -0
  14. package/dist/adapters/hook-events.js +86 -0
  15. package/dist/adapters/sync-claude-code.js +85 -0
  16. package/dist/cli/ai-budget-warning.js +19 -0
  17. package/dist/cli/commands/agent-event.js +80 -0
  18. package/dist/cli/commands/checkpoint.js +36 -0
  19. package/dist/cli/commands/context.js +27 -0
  20. package/dist/cli/commands/daemon.js +139 -0
  21. package/dist/cli/commands/export.js +36 -0
  22. package/dist/cli/commands/init.js +63 -0
  23. package/dist/cli/commands/key.js +65 -0
  24. package/dist/cli/commands/license.js +57 -0
  25. package/dist/cli/commands/milestone.js +64 -0
  26. package/dist/cli/commands/prompt.js +75 -0
  27. package/dist/cli/commands/recap.js +48 -0
  28. package/dist/cli/commands/restore-fork.js +101 -0
  29. package/dist/cli/commands/status.js +26 -0
  30. package/dist/cli/commands/test.js +33 -0
  31. package/dist/cli/commands/timeline.js +23 -0
  32. package/dist/cli/commands/why.js +71 -0
  33. package/dist/cli/context.js +48 -0
  34. package/dist/cli/index.js +77 -0
  35. package/dist/cli/license-gate.js +26 -0
  36. package/dist/cli/preflight.js +28 -0
  37. package/dist/core/agent-context.js +46 -0
  38. package/dist/core/agent-hooks.js +68 -0
  39. package/dist/core/ai-budget.js +27 -0
  40. package/dist/core/chat-retrieval.js +36 -0
  41. package/dist/core/context-tree.js +135 -0
  42. package/dist/core/diff.js +30 -0
  43. package/dist/core/engine.js +321 -0
  44. package/dist/core/export.js +98 -0
  45. package/dist/core/file-history.js +31 -0
  46. package/dist/core/manifest.js +102 -0
  47. package/dist/core/recap.js +49 -0
  48. package/dist/core/test-runner.js +23 -0
  49. package/dist/daemon/listen.js +26 -0
  50. package/dist/daemon/pidfile.js +36 -0
  51. package/dist/daemon/server.js +554 -0
  52. package/dist/db/database.js +631 -0
  53. package/dist/db/schema.js +186 -0
  54. package/dist/git/git.js +22 -0
  55. package/dist/licensing/config.js +28 -0
  56. package/dist/licensing/license-client.js +45 -0
  57. package/dist/licensing/license-manager.js +89 -0
  58. package/dist/licensing/license-payload.js +12 -0
  59. package/dist/licensing/license-store.js +96 -0
  60. package/dist/licensing/machine-id.js +78 -0
  61. package/dist/licensing/secret-store.js +102 -0
  62. package/dist/licensing/verify-license.js +20 -0
  63. package/dist/llm/agent-prompt.js +36 -0
  64. package/dist/llm/annotate-checkpoint.js +67 -0
  65. package/dist/llm/annotate.js +11 -0
  66. package/dist/llm/anthropic-provider.js +97 -0
  67. package/dist/llm/chat.js +35 -0
  68. package/dist/llm/community-pricing.js +75 -0
  69. package/dist/llm/factory.js +17 -0
  70. package/dist/llm/http-error.js +25 -0
  71. package/dist/llm/noop-provider.js +19 -0
  72. package/dist/llm/openai-provider.js +85 -0
  73. package/dist/llm/pricing.js +55 -0
  74. package/dist/llm/prompt.js +103 -0
  75. package/dist/llm/provider.js +2 -0
  76. package/dist/llm/schema.js +49 -0
  77. package/dist/llm/social-post.js +49 -0
  78. package/dist/objects/store.js +31 -0
  79. package/dist/shared/config.js +45 -0
  80. package/dist/shared/redaction.js +18 -0
  81. package/dist/shared/types.js +2 -0
  82. package/dist/watcher/ignore-rules.js +92 -0
  83. package/dist/watcher/watcher.js +249 -0
  84. package/package.json +55 -0
@@ -0,0 +1,30 @@
1
+ import { createTwoFilesPatch, diffLines } from "diff";
2
+ export function lineDiffStats(before, after) {
3
+ const parts = diffLines(before, after);
4
+ let linesAdded = 0;
5
+ let linesRemoved = 0;
6
+ for (const part of parts) {
7
+ const lineCount = part.value.endsWith("\n") ? part.value.split("\n").length - 1 : part.value.split("\n").length;
8
+ if (part.added)
9
+ linesAdded += lineCount;
10
+ if (part.removed)
11
+ linesRemoved += lineCount;
12
+ }
13
+ return { linesAdded, linesRemoved };
14
+ }
15
+ export function unifiedDiff(filePath, before, after) {
16
+ return createTwoFilesPatch(filePath, filePath, before, after, "before", "after");
17
+ }
18
+ const isProbablyBinary = (buf) => buf.subarray(0, 8000).includes(0);
19
+ export function computeFileDiff(store, change) {
20
+ const beforeBuf = change.beforeHash ? store.read(change.beforeHash) : Buffer.alloc(0);
21
+ const afterBuf = change.afterHash ? store.read(change.afterHash) : Buffer.alloc(0);
22
+ if (isProbablyBinary(beforeBuf) || isProbablyBinary(afterBuf)) {
23
+ return { unified: "Binary file differs.", linesAdded: 0, linesRemoved: 0, binary: true };
24
+ }
25
+ const before = beforeBuf.toString("utf-8");
26
+ const after = afterBuf.toString("utf-8");
27
+ const stats = lineDiffStats(before, after);
28
+ return { unified: unifiedDiff(change.filePath, before, after), ...stats, binary: false };
29
+ }
30
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1,321 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { buildManifest, diffManifests } from "./manifest.js";
5
+ import { computeFileDiff } from "./diff.js";
6
+ import { detectGit } from "../git/git.js";
7
+ import { hashContent } from "../objects/store.js";
8
+ import { isAutoGeneratedDocFile } from "./context-tree.js";
9
+ // A fixed-size digest of "every tracked file's path and content hash",
10
+ // stored per state as a cheap way to tell whether two states represent an
11
+ // identical working tree. This was previously storing the raw, un-hashed
12
+ // "path:hash\npath:hash\n..." string directly — at thousands of tracked
13
+ // files that's hundreds of KB of text duplicated into every single state
14
+ // row, unconditionally, and was confirmed (via dbstat on a synthetic
15
+ // 4,000-file, 300-checkpoint project) to be the actual dominant cost in
16
+ // db.sqlite — over 10x bigger than state_manifest itself, not just a
17
+ // secondary factor. Nothing in the codebase reads working_tree_hash for
18
+ // comparison today, so this was pure write-only dead weight; hashing it
19
+ // down to 64 hex chars keeps the field genuinely useful for that purpose
20
+ // going forward at negligible storage cost.
21
+ function manifestFingerprint(manifest) {
22
+ const raw = manifest.map((m) => `${m.filePath}:${m.contentHash}`).join("\n");
23
+ return hashContent(Buffer.from(raw, "utf-8"));
24
+ }
25
+ function storeManifestBlobs(ctx, manifest, changes) {
26
+ const changedPaths = new Set(changes.filter((c) => c.changeType !== "deleted").map((c) => c.filePath));
27
+ for (const entry of manifest) {
28
+ if (!changedPaths.has(entry.filePath))
29
+ continue;
30
+ if (ctx.store.has(entry.contentHash))
31
+ continue;
32
+ const content = readFileSync(join(ctx.projectRoot, entry.filePath));
33
+ ctx.store.write(content);
34
+ }
35
+ }
36
+ function withLineStats(ctx, changes) {
37
+ return changes.map((change) => {
38
+ const diff = computeFileDiff(ctx.store, change);
39
+ return { ...change, linesAdded: diff.linesAdded, linesRemoved: diff.linesRemoved };
40
+ });
41
+ }
42
+ // Every Nth regular checkpoint gets a full manifest snapshot ("keyframe");
43
+ // the rest rely on getStateManifest's delta-chain reconstruction. Bounds
44
+ // reconstruction to at most this many ancestor hops (the initial state is
45
+ // always a keyframe, so the walk always terminates) while cutting
46
+ // state_manifest's write volume by roughly this factor — confirmed via a
47
+ // synthetic 4,000-file, 300-checkpoint run: state_manifest was the
48
+ // dominant driver of db.sqlite growing ~1.1MB/checkpoint. Deliberately
49
+ // small enough that a restore/fork's reconstruction replay (bounded by
50
+ // this same interval) stays fast even on a large project.
51
+ const MANIFEST_KEYFRAME_INTERVAL = 20;
52
+ // Safety snapshots (before restore/fork) and restore/fork states themselves
53
+ // always keep a full manifest (see snapshotCurrentDisk, restoreState,
54
+ // forkState) — they're comparatively rare, and keeping them fully
55
+ // keyframed means anything forked or restored from is immediately fast to
56
+ // reconstruct from. Only this regular, high-frequency checkpoint path is
57
+ // sparsified.
58
+ function shouldStoreFullManifest(ctx, parentStateId) {
59
+ let cur = parentStateId;
60
+ for (let i = 0; i < MANIFEST_KEYFRAME_INTERVAL; i++) {
61
+ if (!cur)
62
+ return false;
63
+ if (ctx.db.hasFullManifest(cur))
64
+ return false;
65
+ cur = ctx.db.getState(cur)?.parentStateId ?? undefined;
66
+ }
67
+ return true;
68
+ }
69
+ function requireSingleProjectId(ctx) {
70
+ const row = ctx.db.raw.prepare(`SELECT id FROM projects ORDER BY created_at ASC LIMIT 1`).get();
71
+ if (!row)
72
+ throw new Error("No project registered in this .whydid database.");
73
+ return row.id;
74
+ }
75
+ export async function initProject(ctx, projectName) {
76
+ const git = await detectGit(ctx.projectRoot);
77
+ const projectId = randomUUID();
78
+ ctx.db.insertProject({ id: projectId, name: projectName, rootPath: ctx.projectRoot, gitRoot: git.root });
79
+ const manifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
80
+ const changes = diffManifests([], manifest);
81
+ storeManifestBlobs(ctx, manifest, changes);
82
+ const changesWithStats = withLineStats(ctx, changes);
83
+ const stateId = randomUUID();
84
+ ctx.db.insertState({
85
+ id: stateId, projectId, parentStateId: null, sessionId: null, turnId: null,
86
+ stateType: "initial", label: "Initial state", summary: null, reason: null,
87
+ decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
88
+ gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(manifest),
89
+ });
90
+ ctx.db.insertFileChanges(stateId, changesWithStats);
91
+ ctx.db.insertStateManifest(stateId, manifest);
92
+ ctx.db.setHeadState(projectId, stateId);
93
+ ctx.db.insertEvent({ id: randomUUID(), projectId, type: "project_initialized", payloadJson: "{}" });
94
+ return { projectId, stateId };
95
+ }
96
+ export async function createCheckpoint(ctx, opts = {}) {
97
+ const projectId = opts.projectId ?? requireSingleProjectId(ctx);
98
+ const head = ctx.db.getHeadState(projectId);
99
+ if (!head)
100
+ throw new Error("No head state found — run initProject first.");
101
+ const previousManifest = ctx.db.getStateManifest(head.id);
102
+ const currentManifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
103
+ const changes = diffManifests(previousManifest, currentManifest);
104
+ if (changes.length === 0) {
105
+ return { stateId: head.id, changed: false, fileChanges: [] };
106
+ }
107
+ // A checkpoint whose *only* changes are whydid's own auto-generated docs
108
+ // (CONTEXT.md, WHYDID_SUMMARY.md) is a checkpoint about nothing — those
109
+ // files only ever change as a side effect of a real checkpoint's own
110
+ // annotation, so treating that write as itself checkpoint-worthy is what
111
+ // causes a self-sustaining loop: checkpoint -> regenerate docs -> that
112
+ // regeneration looks like a new change -> checkpoint again, forever. A
113
+ // checkpoint that changes real files *and* incidentally touches these is
114
+ // unaffected — only an all-docs diff is skipped.
115
+ if (changes.every((c) => isAutoGeneratedDocFile(c.filePath))) {
116
+ return { stateId: head.id, changed: false, fileChanges: [] };
117
+ }
118
+ storeManifestBlobs(ctx, currentManifest, changes);
119
+ const changesWithStats = withLineStats(ctx, changes);
120
+ const git = await detectGit(ctx.projectRoot);
121
+ const stateId = randomUUID();
122
+ ctx.db.insertState({
123
+ id: stateId, projectId, parentStateId: head.id, sessionId: opts.sessionId ?? null, turnId: opts.turnId ?? null,
124
+ stateType: opts.stateType ?? "checkpoint", label: opts.label ?? null, summary: null,
125
+ reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
126
+ gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(currentManifest),
127
+ });
128
+ ctx.db.insertFileChanges(stateId, changesWithStats);
129
+ if (shouldStoreFullManifest(ctx, head.id)) {
130
+ ctx.db.insertStateManifest(stateId, currentManifest);
131
+ }
132
+ ctx.db.setHeadState(projectId, stateId);
133
+ ctx.db.insertEvent({ id: randomUUID(), projectId, type: "checkpoint_created", payloadJson: JSON.stringify({ stateId }) });
134
+ return { stateId, changed: true, fileChanges: changesWithStats };
135
+ }
136
+ function readdirSyncSafe(dir) {
137
+ return existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : [];
138
+ }
139
+ function collectFiles(dir, root, ignoreRules, out) {
140
+ for (const entry of readdirSyncSafe(dir)) {
141
+ const abs = join(dir, entry.name);
142
+ const rel = relative(root, abs).split("\\").join("/");
143
+ if (ignoreRules.isIgnored(entry.isDirectory() ? `${rel}/` : rel))
144
+ continue;
145
+ if (entry.isDirectory())
146
+ collectFiles(abs, root, ignoreRules, out);
147
+ else if (entry.isFile())
148
+ out.push(abs);
149
+ }
150
+ }
151
+ function applyManifestToDisk(ctx, targetManifest) {
152
+ const targetPaths = new Set(targetManifest.map((e) => e.filePath));
153
+ const currentAbsFiles = [];
154
+ collectFiles(ctx.projectRoot, ctx.projectRoot, ctx.ignoreRules, currentAbsFiles);
155
+ for (const abs of currentAbsFiles) {
156
+ const rel = relative(ctx.projectRoot, abs).split("\\").join("/");
157
+ if (!targetPaths.has(rel)) {
158
+ unlinkSync(abs);
159
+ }
160
+ }
161
+ for (const entry of targetManifest) {
162
+ const abs = join(ctx.projectRoot, entry.filePath);
163
+ const content = ctx.store.read(entry.contentHash);
164
+ mkdirSync(dirname(abs), { recursive: true });
165
+ writeFileSync(abs, content);
166
+ }
167
+ }
168
+ async function snapshotCurrentDisk(ctx, projectId, parentStateId, stateType, label) {
169
+ const previousManifest = ctx.db.getStateManifest(parentStateId);
170
+ const currentManifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
171
+ const changes = diffManifests(previousManifest, currentManifest);
172
+ storeManifestBlobs(ctx, currentManifest, changes);
173
+ const changesWithStats = withLineStats(ctx, changes);
174
+ const stateId = randomUUID();
175
+ const git = await detectGit(ctx.projectRoot);
176
+ ctx.db.insertState({
177
+ id: stateId, projectId, parentStateId, sessionId: null, turnId: null,
178
+ stateType, label, summary: null, reason: null, decisionNotes: null, riskLevel: null,
179
+ createdAt: new Date().toISOString(), gitCommitSha: git.commitSha,
180
+ workingTreeHash: manifestFingerprint(currentManifest),
181
+ });
182
+ ctx.db.insertFileChanges(stateId, changesWithStats);
183
+ ctx.db.insertStateManifest(stateId, currentManifest);
184
+ return stateId;
185
+ }
186
+ export async function restoreState(ctx, projectId, targetStateId) {
187
+ const head = ctx.db.getHeadState(projectId);
188
+ if (!head)
189
+ throw new Error("No head state found.");
190
+ const target = ctx.db.getState(targetStateId);
191
+ if (!target)
192
+ throw new Error(`Unknown state: ${targetStateId}`);
193
+ // Preserve whatever is currently on disk as a real state first.
194
+ const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before restore");
195
+ // Apply target manifest to the working tree.
196
+ const targetManifest = ctx.db.getStateManifest(targetStateId);
197
+ applyManifestToDisk(ctx, targetManifest);
198
+ // Record the restore as a new state, parented on the actual restore
199
+ // target — not the safety snapshot — so the state graph shows the real
200
+ // branch: the target now has two futures, the original one (preserved via
201
+ // the safety snapshot, itself parented on the pre-restore head) and this
202
+ // new restored one. This is what makes the timeline an actual graph
203
+ // instead of a straight line no matter how many times you restore.
204
+ const restoredStateId = randomUUID();
205
+ const git = await detectGit(ctx.projectRoot);
206
+ const changesFromSafety = withLineStats(ctx, diffManifests(ctx.db.getStateManifest(safetyStateId), targetManifest));
207
+ ctx.db.insertState({
208
+ id: restoredStateId, projectId, parentStateId: targetStateId, sessionId: null, turnId: null,
209
+ stateType: "restore", label: `Restored to ${target.label ?? target.id}`, summary: null,
210
+ reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
211
+ gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(targetManifest),
212
+ });
213
+ ctx.db.insertFileChanges(restoredStateId, changesFromSafety);
214
+ ctx.db.insertStateManifest(restoredStateId, targetManifest);
215
+ ctx.db.setHeadState(projectId, restoredStateId);
216
+ ctx.db.insertEvent({
217
+ id: randomUUID(), projectId, type: "state_restored",
218
+ payloadJson: JSON.stringify({ targetStateId, safetyStateId, restoredStateId }),
219
+ });
220
+ return { safetyStateId, restoredStateId };
221
+ }
222
+ /**
223
+ * Restores only specific files from a target state, leaving everything
224
+ * else on disk exactly as it is. For a file the target state didn't have
225
+ * (it was added after that point), "restoring" it means deleting it now —
226
+ * matching what a full restore would do to that same file.
227
+ *
228
+ * Unlike a full restore, the resulting state is parented on the safety
229
+ * snapshot, not the target — the disk state here is overwhelmingly a
230
+ * continuation of current work with a few files patched in from history,
231
+ * not a jump back to the target's own timeline. A full restore's "the
232
+ * target now has two futures" framing doesn't apply when only one file
233
+ * out of dozens came from that target.
234
+ */
235
+ export async function restoreFiles(ctx, projectId, targetStateId, filePaths) {
236
+ const head = ctx.db.getHeadState(projectId);
237
+ if (!head)
238
+ throw new Error("No head state found.");
239
+ const target = ctx.db.getState(targetStateId);
240
+ if (!target)
241
+ throw new Error(`Unknown state: ${targetStateId}`);
242
+ if (filePaths.length === 0)
243
+ throw new Error("No files specified to restore.");
244
+ const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before selective restore");
245
+ const targetManifest = ctx.db.getStateManifest(targetStateId);
246
+ const targetByPath = new Map(targetManifest.map((e) => [e.filePath, e]));
247
+ const filesRestored = [];
248
+ for (const filePath of filePaths) {
249
+ const abs = join(ctx.projectRoot, filePath);
250
+ const entry = targetByPath.get(filePath);
251
+ if (entry) {
252
+ const content = ctx.store.read(entry.contentHash);
253
+ mkdirSync(dirname(abs), { recursive: true });
254
+ writeFileSync(abs, content);
255
+ filesRestored.push(filePath);
256
+ }
257
+ else if (existsSync(abs)) {
258
+ unlinkSync(abs);
259
+ filesRestored.push(filePath);
260
+ }
261
+ }
262
+ const safetyManifest = ctx.db.getStateManifest(safetyStateId);
263
+ const restoredSet = new Set(filesRestored);
264
+ const newManifest = safetyManifest.filter((e) => !restoredSet.has(e.filePath));
265
+ for (const filePath of filesRestored) {
266
+ const entry = targetByPath.get(filePath);
267
+ if (entry)
268
+ newManifest.push(entry);
269
+ }
270
+ const restoredStateId = randomUUID();
271
+ const git = await detectGit(ctx.projectRoot);
272
+ const changesFromSafety = withLineStats(ctx, diffManifests(safetyManifest, newManifest));
273
+ ctx.db.insertState({
274
+ id: restoredStateId, projectId, parentStateId: safetyStateId, sessionId: null, turnId: null,
275
+ stateType: "restore",
276
+ label: `Restored ${filesRestored.length} file${filesRestored.length === 1 ? "" : "s"} from ${target.label ?? target.id.slice(0, 8)}`,
277
+ summary: null, reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
278
+ gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(newManifest),
279
+ });
280
+ ctx.db.insertFileChanges(restoredStateId, changesFromSafety);
281
+ ctx.db.insertStateManifest(restoredStateId, newManifest);
282
+ ctx.db.setHeadState(projectId, restoredStateId);
283
+ ctx.db.insertEvent({
284
+ id: randomUUID(), projectId, type: "state_restored",
285
+ payloadJson: JSON.stringify({ targetStateId, safetyStateId, restoredStateId, filePaths: filesRestored, selective: true }),
286
+ });
287
+ return { safetyStateId, restoredStateId, filesRestored };
288
+ }
289
+ export async function forkState(ctx, projectId, sourceStateId, forkName) {
290
+ const head = ctx.db.getHeadState(projectId);
291
+ if (!head)
292
+ throw new Error("No head state found.");
293
+ const source = ctx.db.getState(sourceStateId);
294
+ if (!source)
295
+ throw new Error(`Unknown state: ${sourceStateId}`);
296
+ const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before fork");
297
+ const sourceManifest = ctx.db.getStateManifest(sourceStateId);
298
+ applyManifestToDisk(ctx, sourceManifest);
299
+ // Same reasoning as restoreState: parent the fork on its actual source
300
+ // state so it becomes a real sibling branch of the source's other
301
+ // children, not a dead-end hanging off the safety snapshot.
302
+ const forkStateId = randomUUID();
303
+ const git = await detectGit(ctx.projectRoot);
304
+ const changesFromSafety = withLineStats(ctx, diffManifests(ctx.db.getStateManifest(safetyStateId), sourceManifest));
305
+ ctx.db.insertState({
306
+ id: forkStateId, projectId, parentStateId: sourceStateId, sessionId: null, turnId: null,
307
+ stateType: "fork", label: `Fork: ${forkName}`, summary: null, reason: null,
308
+ decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
309
+ gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(sourceManifest),
310
+ });
311
+ ctx.db.insertFileChanges(forkStateId, changesFromSafety);
312
+ ctx.db.insertStateManifest(forkStateId, sourceManifest);
313
+ ctx.db.setHeadState(projectId, forkStateId);
314
+ ctx.db.insertFork({ id: randomUUID(), projectId, sourceStateId, name: forkName });
315
+ ctx.db.insertEvent({
316
+ id: randomUUID(), projectId, type: "state_forked",
317
+ payloadJson: JSON.stringify({ sourceStateId, forkStateId, forkName }),
318
+ });
319
+ return { safetyStateId, forkStateId };
320
+ }
321
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1,98 @@
1
+ export function buildExportData(db, project) {
2
+ const states = db.listStates(project.id).map((s) => {
3
+ const annotation = db.getAnnotation(s.id);
4
+ const changes = db.getFileChanges(s.id);
5
+ return {
6
+ id: s.id,
7
+ label: s.label,
8
+ stateType: s.stateType,
9
+ createdAt: s.createdAt,
10
+ riskLevel: annotation?.riskLevel ?? s.riskLevel,
11
+ summary: annotation?.status === "generated" ? annotation.summary : s.summary,
12
+ reason: annotation?.status === "generated" ? annotation.reason : s.reason,
13
+ decisions: annotation?.status === "generated" ? annotation.decisions : [],
14
+ annotationStatus: annotation?.status ?? null,
15
+ fileChanges: changes.map((c) => ({
16
+ filePath: c.filePath, changeType: c.changeType, linesAdded: c.linesAdded, linesRemoved: c.linesRemoved,
17
+ })),
18
+ };
19
+ });
20
+ return { project: { name: project.name, exportedAt: new Date().toISOString() }, states };
21
+ }
22
+ export function exportAsJson(data) {
23
+ return JSON.stringify(data, null, 2);
24
+ }
25
+ export function exportAsMarkdown(data) {
26
+ const lines = [`# ${data.project.name} — whydid export`, "", `Exported: ${data.project.exportedAt}`, ""];
27
+ for (const s of data.states) {
28
+ lines.push(`## ${s.label ?? s.stateType} (#${s.id.slice(0, 8)})`, "");
29
+ lines.push(`- Type: ${s.stateType}`, `- Created: ${s.createdAt}`);
30
+ if (s.riskLevel)
31
+ lines.push(`- Risk: ${s.riskLevel}`);
32
+ lines.push("");
33
+ if (s.reason)
34
+ lines.push(`**Why:** ${s.reason}`, "");
35
+ if (s.decisions.length > 0) {
36
+ lines.push("**Decisions:**");
37
+ for (const d of s.decisions)
38
+ lines.push(`- ${d}`);
39
+ lines.push("");
40
+ }
41
+ lines.push(`**Files changed (${s.fileChanges.length}):**`);
42
+ for (const c of s.fileChanges)
43
+ lines.push(`- \`${c.filePath}\` (${c.changeType}, +${c.linesAdded}/-${c.linesRemoved})`);
44
+ lines.push("");
45
+ }
46
+ return lines.join("\n");
47
+ }
48
+ function escapeHtml(text) {
49
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
50
+ }
51
+ export function exportAsHtml(data) {
52
+ const stateBlocks = data.states
53
+ .map((s) => {
54
+ const decisions = s.decisions.map((d) => `<li>${escapeHtml(d)}</li>`).join("");
55
+ const files = s.fileChanges
56
+ .map((c) => `<li><code>${escapeHtml(c.filePath)}</code> <span class="change-type">${c.changeType}</span> <span class="stats">+${c.linesAdded}/-${c.linesRemoved}</span></li>`)
57
+ .join("");
58
+ return `
59
+ <section class="state">
60
+ <h2>${escapeHtml(s.label ?? s.stateType)} <span class="state-id">#${s.id.slice(0, 8)}</span></h2>
61
+ <p class="meta">${escapeHtml(s.stateType)} · ${escapeHtml(s.createdAt)}${s.riskLevel ? ` · <span class="risk risk-${s.riskLevel}">${s.riskLevel} risk</span>` : ""}</p>
62
+ ${s.reason ? `<p class="why"><strong>Why:</strong> ${escapeHtml(s.reason)}</p>` : ""}
63
+ ${decisions ? `<p><strong>Decisions:</strong></p><ul>${decisions}</ul>` : ""}
64
+ <p><strong>Files changed (${s.fileChanges.length}):</strong></p>
65
+ <ul class="files">${files}</ul>
66
+ </section>`;
67
+ })
68
+ .join("\n");
69
+ return `<!doctype html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="UTF-8" />
73
+ <title>${escapeHtml(data.project.name)} — whydid export</title>
74
+ <style>
75
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 760px; margin: 0 auto; padding: 2rem 1.5rem; color: #1a1a1a; background: #fff; }
76
+ h1 { font-size: 1.6rem; }
77
+ h2 { font-size: 1.15rem; margin-bottom: 0.25rem; }
78
+ .state-id { color: #888; font-weight: normal; font-size: 0.85em; }
79
+ .meta { color: #666; font-size: 0.9em; margin-top: 0; }
80
+ .why { background: #f6f6f6; padding: 0.75rem 1rem; border-radius: 6px; }
81
+ .risk { padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8em; }
82
+ .risk-low { background: #dcfce7; color: #166534; }
83
+ .risk-medium { background: #fef3c7; color: #92400e; }
84
+ .risk-high { background: #fee2e2; color: #991b1b; }
85
+ ul.files { font-size: 0.9em; }
86
+ .change-type { color: #888; text-transform: uppercase; font-size: 0.75em; }
87
+ .stats { color: #888; font-size: 0.85em; }
88
+ section.state { border-top: 1px solid #e5e5e5; padding: 1.5rem 0; }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <h1>${escapeHtml(data.project.name)}</h1>
93
+ <p class="meta">whydid export — ${escapeHtml(data.project.exportedAt)}</p>
94
+ ${stateBlocks}
95
+ </body>
96
+ </html>`;
97
+ }
98
+ //# sourceMappingURL=export.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every state that touched a given file, oldest first, across the whole
3
+ * project history (not just one branch) — so a later change that lands on
4
+ * top of an earlier one in the same file is visible, even if the user asks
5
+ * about it long after both happened.
6
+ */
7
+ export function getFileHistory(db, projectId, filePath) {
8
+ const states = db.listStates(projectId).slice().reverse(); // oldest first
9
+ const entries = [];
10
+ for (const state of states) {
11
+ const change = db.getFileChanges(state.id).find((c) => c.filePath === filePath);
12
+ if (!change)
13
+ continue;
14
+ const annotation = db.getAnnotation(state.id);
15
+ const turn = state.turnId ? db.getTurn(state.turnId) : undefined;
16
+ entries.push({
17
+ stateId: state.id,
18
+ stateLabel: state.label,
19
+ stateType: state.stateType,
20
+ createdAt: state.createdAt,
21
+ changeType: change.changeType,
22
+ turnPrompt: turn?.prompt ?? null,
23
+ annotationSummary: annotation?.status === "generated" ? annotation.summary : null,
24
+ annotationReason: annotation?.status === "generated" ? annotation.reason : null,
25
+ annotationStatus: annotation?.status ?? null,
26
+ buildsOnPriorChange: entries.length > 0 && change.changeType !== "added",
27
+ });
28
+ }
29
+ return entries;
30
+ }
31
+ //# sourceMappingURL=file-history.js.map
@@ -0,0 +1,102 @@
1
+ import { readdirSync, statSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { hashContent } from "../objects/store.js";
4
+ const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024;
5
+ function walk(dir, root, ignoreRules, out) {
6
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
7
+ const abs = join(dir, entry.name);
8
+ const rel = relative(root, abs).split("\\").join("/");
9
+ if (ignoreRules.isIgnored(entry.isDirectory() ? `${rel}/` : rel))
10
+ continue;
11
+ if (entry.isDirectory()) {
12
+ walk(abs, root, ignoreRules, out);
13
+ }
14
+ else if (entry.isFile()) {
15
+ out.push(abs);
16
+ }
17
+ }
18
+ }
19
+ // Guard against the classic "racy stat cache" bug (the same one git works
20
+ // around): if a file's mtime is very close to "now", a filesystem with
21
+ // coarse (e.g. 1s) mtime resolution could report the same mtime for two
22
+ // different writes made in quick succession. Refuse to cache — and refuse
23
+ // to trust a cached entry for — any file whose mtime falls within this
24
+ // window of the current scan, so a same-tick rewrite always gets re-hashed
25
+ // instead of silently reusing a stale hash.
26
+ const RACY_WINDOW_MS = 1100;
27
+ // Every checkpoint used to re-read and re-hash every tracked file's full
28
+ // content, even when nothing about the file changed. On a project with
29
+ // tens of thousands of tracked files that made every checkpoint cost
30
+ // seconds regardless of how small the actual edit was. Content hashes are
31
+ // immutable for a given (path, mtime, size) triple in real editing
32
+ // workflows, so this cache lets a file skip its content read+hash
33
+ // entirely when neither its mtime nor its size moved since the last scan
34
+ // of this project root — the returned manifest is byte-identical either
35
+ // way, only the cost of producing it changes. The cache is in-memory only
36
+ // (keyed by project root) and rebuilds itself on first scan after a
37
+ // process restart, so there is no persistence/correctness surface to get
38
+ // wrong.
39
+ const statCache = new Map();
40
+ export async function buildManifest(projectRoot, ignoreRules, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE) {
41
+ const absFiles = [];
42
+ walk(projectRoot, projectRoot, ignoreRules, absFiles);
43
+ let cache = statCache.get(projectRoot);
44
+ if (!cache) {
45
+ cache = new Map();
46
+ statCache.set(projectRoot, cache);
47
+ }
48
+ const scanStartMs = Date.now();
49
+ const entries = [];
50
+ const seenPaths = new Set();
51
+ for (const abs of absFiles) {
52
+ const stat = statSync(abs);
53
+ if (stat.size > maxFileSizeBytes)
54
+ continue;
55
+ const relPath = relative(projectRoot, abs).split("\\").join("/");
56
+ seenPaths.add(relPath);
57
+ const isRacy = scanStartMs - stat.mtimeMs < RACY_WINDOW_MS;
58
+ const cached = cache.get(relPath);
59
+ let contentHash;
60
+ if (!isRacy && cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
61
+ contentHash = cached.contentHash;
62
+ }
63
+ else {
64
+ const content = readFileSync(abs);
65
+ contentHash = hashContent(content);
66
+ if (!isRacy)
67
+ cache.set(relPath, { mtimeMs: stat.mtimeMs, size: stat.size, contentHash });
68
+ else
69
+ cache.delete(relPath);
70
+ }
71
+ entries.push({ filePath: relPath, contentHash, sizeBytes: stat.size });
72
+ }
73
+ // Drop cache entries for files that no longer exist so the cache doesn't
74
+ // grow unbounded across a project's lifetime (renames, deletes, etc.).
75
+ for (const cachedPath of cache.keys()) {
76
+ if (!seenPaths.has(cachedPath))
77
+ cache.delete(cachedPath);
78
+ }
79
+ entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
80
+ return entries;
81
+ }
82
+ export function diffManifests(before, after) {
83
+ const beforeMap = new Map(before.map((e) => [e.filePath, e]));
84
+ const afterMap = new Map(after.map((e) => [e.filePath, e]));
85
+ const changes = [];
86
+ for (const [filePath, afterEntry] of afterMap) {
87
+ const beforeEntry = beforeMap.get(filePath);
88
+ if (!beforeEntry) {
89
+ changes.push({ filePath, changeType: "added", beforeHash: null, afterHash: afterEntry.contentHash, linesAdded: 0, linesRemoved: 0 });
90
+ }
91
+ else if (beforeEntry.contentHash !== afterEntry.contentHash) {
92
+ changes.push({ filePath, changeType: "modified", beforeHash: beforeEntry.contentHash, afterHash: afterEntry.contentHash, linesAdded: 0, linesRemoved: 0 });
93
+ }
94
+ }
95
+ for (const [filePath, beforeEntry] of beforeMap) {
96
+ if (!afterMap.has(filePath)) {
97
+ changes.push({ filePath, changeType: "deleted", beforeHash: beforeEntry.contentHash, afterHash: null, linesAdded: 0, linesRemoved: 0 });
98
+ }
99
+ }
100
+ return changes;
101
+ }
102
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1,49 @@
1
+ const SKIP_SEGMENTS = new Set(["src", "lib", "app", "apps", "packages", "test", "tests", "__tests__"]);
2
+ function deriveArea(filePath) {
3
+ const parts = filePath.split("/");
4
+ const directories = parts.slice(0, -1).filter((p) => !SKIP_SEGMENTS.has(p.toLowerCase()));
5
+ if (directories.length === 0)
6
+ return null;
7
+ const raw = directories[0];
8
+ return raw.charAt(0).toUpperCase() + raw.slice(1);
9
+ }
10
+ export function computeRecap(db, projectId, sinceIso) {
11
+ const states = db.listStates(projectId).filter((s) => s.createdAt >= sinceIso && s.stateType !== "initial");
12
+ const fileChurn = new Map();
13
+ const areaCounts = new Map();
14
+ const riskyAreas = new Set();
15
+ let unexplainedChanges = 0;
16
+ for (const state of states) {
17
+ const changes = db.getFileChanges(state.id);
18
+ for (const change of changes) {
19
+ fileChurn.set(change.filePath, (fileChurn.get(change.filePath) ?? 0) + 1);
20
+ const area = deriveArea(change.filePath);
21
+ if (area)
22
+ areaCounts.set(area, (areaCounts.get(area) ?? 0) + 1);
23
+ }
24
+ const annotation = db.getAnnotation(state.id);
25
+ if (!annotation || annotation.status !== "generated") {
26
+ unexplainedChanges += 1;
27
+ }
28
+ else if (annotation.riskLevel === "medium" || annotation.riskLevel === "high") {
29
+ riskyAreas.add(annotation.summary || state.label || `State #${state.id.slice(0, 8)}`);
30
+ }
31
+ }
32
+ const highChurnFiles = [...fileChurn.entries()]
33
+ .sort((a, b) => b[1] - a[1])
34
+ .slice(0, 5)
35
+ .map(([filePath, changes]) => ({ filePath, changes }));
36
+ const mainAreas = [...areaCounts.entries()]
37
+ .sort((a, b) => b[1] - a[1])
38
+ .slice(0, 5)
39
+ .map(([area]) => area);
40
+ return {
41
+ totalChanges: states.length,
42
+ mainAreas,
43
+ highChurnFiles,
44
+ riskyAreas: [...riskyAreas].slice(0, 5),
45
+ unexplainedChanges,
46
+ latestTestRun: db.getLatestTestRunForProject(projectId) ?? null,
47
+ };
48
+ }
49
+ //# sourceMappingURL=recap.js.map
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { exec } from "node:child_process";
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
6
+ export async function runTestCommand(ctx, stateId, command, timeoutMs = DEFAULT_TIMEOUT_MS) {
7
+ const logsDir = join(ctx.whydidDir, "logs");
8
+ mkdirSync(logsDir, { recursive: true });
9
+ const outputPath = join(logsDir, `test-${stateId}.log`);
10
+ const start = Date.now();
11
+ const { exitCode, output } = await new Promise((resolve) => {
12
+ exec(command, { cwd: ctx.projectRoot, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
13
+ resolve({ exitCode: error ? (typeof error.code === "number" ? error.code : 1) : 0, output: `${stdout}\n${stderr}` });
14
+ });
15
+ });
16
+ const durationMs = Date.now() - start;
17
+ writeFileSync(outputPath, output);
18
+ const status = exitCode === 0 ? "passed" : "failed";
19
+ const id = randomUUID();
20
+ ctx.db.insertTestRun({ id, stateId, command, status, exitCode, durationMs, outputPath });
21
+ return { id, status, exitCode, durationMs, outputPath };
22
+ }
23
+ //# sourceMappingURL=test-runner.js.map