holistic 0.2.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 (66) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/CONTRIBUTING.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +329 -0
  5. package/bin/holistic +17 -0
  6. package/bin/holistic.cmd +23 -0
  7. package/bin/holistic.js +59 -0
  8. package/dist/__tests__/mcp-notification.test.d.ts +5 -0
  9. package/dist/__tests__/mcp-notification.test.d.ts.map +1 -0
  10. package/dist/__tests__/mcp-notification.test.js +255 -0
  11. package/dist/__tests__/mcp-notification.test.js.map +1 -0
  12. package/dist/cli.d.ts +6 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +637 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/core/docs.d.ts +3 -0
  17. package/dist/core/docs.d.ts.map +1 -0
  18. package/dist/core/docs.js +602 -0
  19. package/dist/core/docs.js.map +1 -0
  20. package/dist/core/git-hooks.d.ts +17 -0
  21. package/dist/core/git-hooks.d.ts.map +1 -0
  22. package/dist/core/git-hooks.js +144 -0
  23. package/dist/core/git-hooks.js.map +1 -0
  24. package/dist/core/git.d.ts +12 -0
  25. package/dist/core/git.d.ts.map +1 -0
  26. package/dist/core/git.js +121 -0
  27. package/dist/core/git.js.map +1 -0
  28. package/dist/core/lock.d.ts +2 -0
  29. package/dist/core/lock.d.ts.map +1 -0
  30. package/dist/core/lock.js +40 -0
  31. package/dist/core/lock.js.map +1 -0
  32. package/dist/core/redact.d.ts +3 -0
  33. package/dist/core/redact.d.ts.map +1 -0
  34. package/dist/core/redact.js +13 -0
  35. package/dist/core/redact.js.map +1 -0
  36. package/dist/core/setup.d.ts +35 -0
  37. package/dist/core/setup.d.ts.map +1 -0
  38. package/dist/core/setup.js +654 -0
  39. package/dist/core/setup.js.map +1 -0
  40. package/dist/core/splash.d.ts +9 -0
  41. package/dist/core/splash.d.ts.map +1 -0
  42. package/dist/core/splash.js +35 -0
  43. package/dist/core/splash.js.map +1 -0
  44. package/dist/core/state.d.ts +42 -0
  45. package/dist/core/state.d.ts.map +1 -0
  46. package/dist/core/state.js +744 -0
  47. package/dist/core/state.js.map +1 -0
  48. package/dist/core/sync.d.ts +12 -0
  49. package/dist/core/sync.d.ts.map +1 -0
  50. package/dist/core/sync.js +106 -0
  51. package/dist/core/sync.js.map +1 -0
  52. package/dist/core/types.d.ts +210 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +2 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/daemon.d.ts +7 -0
  57. package/dist/daemon.d.ts.map +1 -0
  58. package/dist/daemon.js +242 -0
  59. package/dist/daemon.js.map +1 -0
  60. package/dist/mcp-server.d.ts +11 -0
  61. package/dist/mcp-server.d.ts.map +1 -0
  62. package/dist/mcp-server.js +266 -0
  63. package/dist/mcp-server.js.map +1 -0
  64. package/docs/handoff-walkthrough.md +119 -0
  65. package/docs/structured-metadata.md +341 -0
  66. package/package.json +67 -0
@@ -0,0 +1,744 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getGitSnapshot, getRecentCommitSubjects, isPortableHolisticPath } from './git.js';
4
+ import { withLockSync } from './lock.js';
5
+ import { sanitizeList, sanitizeText } from './redact.js';
6
+ function now() {
7
+ return new Date().toISOString();
8
+ }
9
+ function projectNameFromRoot(rootDir) {
10
+ return path.basename(rootDir);
11
+ }
12
+ function normalizeRelativePath(value) {
13
+ return value.replaceAll("\\", "/");
14
+ }
15
+ function relativeToRoot(rootDir, absolutePath) {
16
+ return normalizeRelativePath(path.relative(rootDir, absolutePath));
17
+ }
18
+ function readRepoRuntimeConfig(rootDir) {
19
+ const configPath = path.join(rootDir, "holistic.repo.json");
20
+ if (!fs.existsSync(configPath)) {
21
+ return {};
22
+ }
23
+ try {
24
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ export function getRuntimePaths(rootDir) {
31
+ const runtime = readRepoRuntimeConfig(rootDir).runtime ?? {};
32
+ const holisticDir = path.join(rootDir, runtime.holisticDir ?? ".holistic");
33
+ const contextDir = path.join(holisticDir, "context");
34
+ const masterDoc = path.join(rootDir, runtime.masterDoc ?? "HOLISTIC.md");
35
+ const agentsDoc = path.join(rootDir, runtime.agentsDoc ?? "AGENTS.md");
36
+ const writeRootHistoryDoc = runtime.writeRootHistoryDoc !== false;
37
+ const writeRootAgentDocs = runtime.writeRootAgentDocs !== false;
38
+ const rootHistoryDoc = writeRootHistoryDoc
39
+ ? path.join(rootDir, runtime.rootHistoryDoc ?? "HISTORY.md")
40
+ : null;
41
+ const rootClaudeDoc = writeRootAgentDocs
42
+ ? path.join(rootDir, runtime.rootClaudeDoc ?? "CLAUDE.md")
43
+ : null;
44
+ const rootGeminiDoc = writeRootAgentDocs
45
+ ? path.join(rootDir, runtime.rootGeminiDoc ?? "GEMINI.md")
46
+ : null;
47
+ const trackedPaths = [
48
+ relativeToRoot(rootDir, masterDoc),
49
+ relativeToRoot(rootDir, agentsDoc),
50
+ rootHistoryDoc ? relativeToRoot(rootDir, rootHistoryDoc) : null,
51
+ rootClaudeDoc ? relativeToRoot(rootDir, rootClaudeDoc) : null,
52
+ rootGeminiDoc ? relativeToRoot(rootDir, rootGeminiDoc) : null,
53
+ relativeToRoot(rootDir, holisticDir),
54
+ ].filter((value, index, list) => Boolean(value) && list.indexOf(value) === index);
55
+ return {
56
+ rootDir,
57
+ holisticDir,
58
+ stateFile: path.join(holisticDir, "state.json"),
59
+ sessionsDir: path.join(holisticDir, "sessions"),
60
+ contextDir,
61
+ adaptersDir: path.join(contextDir, "adapters"),
62
+ masterDoc,
63
+ agentsDoc,
64
+ rootHistoryDoc,
65
+ rootClaudeDoc,
66
+ rootGeminiDoc,
67
+ currentPlanDoc: path.join(contextDir, "current-plan.md"),
68
+ protocolDoc: path.join(contextDir, "session-protocol.md"),
69
+ historyDoc: path.join(contextDir, "project-history.md"),
70
+ regressionDoc: path.join(contextDir, "regression-watch.md"),
71
+ zeroTouchDoc: path.join(contextDir, "zero-touch.md"),
72
+ trackedPaths,
73
+ };
74
+ }
75
+ function defaultDocIndex(paths) {
76
+ return {
77
+ masterDoc: relativeToRoot(paths.rootDir, paths.masterDoc),
78
+ stateFile: relativeToRoot(paths.rootDir, paths.stateFile),
79
+ sessionsDir: relativeToRoot(paths.rootDir, paths.sessionsDir),
80
+ contextDir: relativeToRoot(paths.rootDir, paths.contextDir),
81
+ adapterDocs: {
82
+ codex: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "codex.md")),
83
+ claude: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "claude-cowork.md")),
84
+ antigravity: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "antigravity.md")),
85
+ gemini: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "gemini.md")),
86
+ copilot: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "copilot.md")),
87
+ cursor: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "cursor.md")),
88
+ goose: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "goose.md")),
89
+ gsd: relativeToRoot(paths.rootDir, path.join(paths.adaptersDir, "gsd.md")),
90
+ },
91
+ currentPlanDoc: relativeToRoot(paths.rootDir, paths.currentPlanDoc),
92
+ protocolDoc: relativeToRoot(paths.rootDir, paths.protocolDoc),
93
+ historyDoc: relativeToRoot(paths.rootDir, paths.historyDoc),
94
+ regressionDoc: relativeToRoot(paths.rootDir, paths.regressionDoc),
95
+ zeroTouchDoc: relativeToRoot(paths.rootDir, paths.zeroTouchDoc),
96
+ };
97
+ }
98
+ function defaultPassiveCapture() {
99
+ return {
100
+ lastObservedBranch: null,
101
+ pendingFiles: [],
102
+ activityTicks: 0,
103
+ quietTicks: 0,
104
+ lastCheckpointAt: null,
105
+ };
106
+ }
107
+ function loadHolisticConfig(rootDir) {
108
+ const configPath = path.join(getRuntimePaths(rootDir).holisticDir, "config.json");
109
+ if (!fs.existsSync(configPath)) {
110
+ return {};
111
+ }
112
+ try {
113
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ function autoInferRepoSignalsEnabled(rootDir) {
120
+ const config = loadHolisticConfig(rootDir);
121
+ return config.autoInferSessions !== false;
122
+ }
123
+ function isPortableHolisticFile(file) {
124
+ return isPortableHolisticPath(file);
125
+ }
126
+ function summarizeFilesForGoal(files) {
127
+ const interesting = files
128
+ .filter((file) => !isPortableHolisticFile(file))
129
+ .slice(0, 3);
130
+ const targets = interesting.length > 0 ? interesting : files.slice(0, 3);
131
+ return targets.join(", ");
132
+ }
133
+ export function inferSessionStart(rootDir, state) {
134
+ const nextPending = state.pendingWork[0];
135
+ if (nextPending) {
136
+ return {
137
+ title: nextPending.title,
138
+ goal: nextPending.recommendedNextStep,
139
+ plan: ["Read HOLISTIC.md", nextPending.recommendedNextStep],
140
+ source: "pending",
141
+ status: nextPending.context,
142
+ nextSteps: [nextPending.recommendedNextStep],
143
+ consumePendingWork: true,
144
+ };
145
+ }
146
+ if (state.lastHandoff) {
147
+ return {
148
+ title: "Continue previous handoff",
149
+ goal: state.lastHandoff.nextAction,
150
+ plan: ["Read HOLISTIC.md", state.lastHandoff.nextAction],
151
+ source: "handoff",
152
+ status: state.lastHandoff.summary,
153
+ blockers: [...state.lastHandoff.blockers],
154
+ nextSteps: [state.lastHandoff.nextAction],
155
+ };
156
+ }
157
+ if (autoInferRepoSignalsEnabled(rootDir)) {
158
+ const snapshot = getGitSnapshot(rootDir, state.repoSnapshot ?? {});
159
+ const changedFiles = snapshot.changedFiles.filter((file) => !isPortableHolisticFile(file));
160
+ if (changedFiles.length > 0) {
161
+ const summary = summarizeFilesForGoal(changedFiles);
162
+ return {
163
+ title: "Continue recent repo work",
164
+ goal: `Continue work around ${summary}`,
165
+ plan: ["Review the most recently changed files", "Continue the current implementation thread"],
166
+ source: "files",
167
+ status: `Inferred a session from recent repo changes on ${snapshot.branch}.`,
168
+ nextSteps: [`Review ${summary}`],
169
+ };
170
+ }
171
+ const recentCommits = getRecentCommitSubjects(rootDir).filter((subject) => subject !== "docs(holistic): handoff");
172
+ if (recentCommits.length > 0) {
173
+ return {
174
+ title: "Continue recent git work",
175
+ goal: `Continue work related to: ${sanitizeText(recentCommits[0])}`,
176
+ plan: ["Review the latest commits", "Continue the most recent implementation thread"],
177
+ source: "git",
178
+ status: "Inferred a session from recent git history.",
179
+ nextSteps: ["Review the latest commit context before continuing"],
180
+ };
181
+ }
182
+ }
183
+ return {
184
+ title: "New work session",
185
+ goal: "Start a new task and capture the first checkpoint.",
186
+ plan: ["Read HOLISTIC.md", "Confirm the next task with the user"],
187
+ source: "default",
188
+ status: "No prior session context could be inferred automatically.",
189
+ };
190
+ }
191
+ export function canInferSessionStart(rootDir, state) {
192
+ return inferSessionStart(rootDir, state).source !== "default";
193
+ }
194
+ export function createInitialState(rootDir) {
195
+ const timestamp = now();
196
+ const paths = getRuntimePaths(rootDir);
197
+ return {
198
+ version: 2,
199
+ projectName: projectNameFromRoot(rootDir),
200
+ createdAt: timestamp,
201
+ updatedAt: timestamp,
202
+ activeSession: null,
203
+ pendingWork: [],
204
+ lastHandoff: null,
205
+ docIndex: defaultDocIndex(paths),
206
+ passiveCapture: defaultPassiveCapture(),
207
+ repoSnapshot: {},
208
+ pendingCommit: null,
209
+ };
210
+ }
211
+ function ensureDirs(paths) {
212
+ fs.mkdirSync(paths.holisticDir, { recursive: true });
213
+ fs.mkdirSync(paths.sessionsDir, { recursive: true });
214
+ fs.mkdirSync(paths.contextDir, { recursive: true });
215
+ fs.mkdirSync(paths.adaptersDir, { recursive: true });
216
+ }
217
+ export function stateLockFile(paths) {
218
+ return `${paths.stateFile}.lock`;
219
+ }
220
+ const CURRENT_STATE_VERSION = 2;
221
+ function migrateState(state, fromVersion, toVersion) {
222
+ let migrated = { ...state };
223
+ migrated.version = toVersion;
224
+ migrated.updatedAt = now();
225
+ if (fromVersion !== toVersion) {
226
+ process.stdout.write(`Migrated Holistic state from v${fromVersion} to v${toVersion}\n`);
227
+ }
228
+ return migrated;
229
+ }
230
+ function hydrateState(state, paths) {
231
+ if (state.version < CURRENT_STATE_VERSION) {
232
+ state = migrateState(state, state.version, CURRENT_STATE_VERSION);
233
+ }
234
+ const defaults = defaultDocIndex(paths);
235
+ state.docIndex = {
236
+ ...(state.docIndex ?? {}),
237
+ ...defaults,
238
+ adapterDocs: {
239
+ ...(state.docIndex?.adapterDocs ?? {}),
240
+ ...defaults.adapterDocs,
241
+ },
242
+ };
243
+ state.passiveCapture = {
244
+ ...defaultPassiveCapture(),
245
+ ...(state.passiveCapture ?? {}),
246
+ pendingFiles: state.passiveCapture?.pendingFiles ?? [],
247
+ };
248
+ state.pendingWork = state.pendingWork ?? [];
249
+ state.repoSnapshot = state.repoSnapshot ?? {};
250
+ state.pendingCommit = state.pendingCommit ?? null;
251
+ return state;
252
+ }
253
+ function loadStateFromDisk(rootDir, paths) {
254
+ if (!fs.existsSync(paths.stateFile)) {
255
+ return { state: createInitialState(rootDir), created: true };
256
+ }
257
+ const raw = fs.readFileSync(paths.stateFile, "utf8");
258
+ return {
259
+ state: hydrateState(JSON.parse(raw), paths),
260
+ created: false,
261
+ };
262
+ }
263
+ export function withStateLock(paths, fn) {
264
+ return withLockSync(stateLockFile(paths), fn);
265
+ }
266
+ export function loadState(rootDir) {
267
+ const paths = getRuntimePaths(rootDir);
268
+ ensureDirs(paths);
269
+ const { state, created } = loadStateFromDisk(rootDir, paths);
270
+ return { state, paths, created };
271
+ }
272
+ export function saveState(paths, state, options) {
273
+ const write = () => {
274
+ state.updatedAt = now();
275
+ const tempFile = `${paths.stateFile}.${process.pid}.tmp`;
276
+ fs.writeFileSync(tempFile, JSON.stringify(state, null, 2) + "\n", "utf8");
277
+ fs.renameSync(tempFile, paths.stateFile);
278
+ };
279
+ if (options?.locked) {
280
+ write();
281
+ return;
282
+ }
283
+ withStateLock(paths, write);
284
+ }
285
+ export function draftHandoffFile(paths) {
286
+ return path.join(paths.holisticDir, "draft-handoff.json");
287
+ }
288
+ export function readDraftHandoff(paths) {
289
+ const filePath = draftHandoffFile(paths);
290
+ if (!fs.existsSync(filePath)) {
291
+ return null;
292
+ }
293
+ try {
294
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ export function writeDraftHandoff(paths, draft) {
301
+ fs.writeFileSync(draftHandoffFile(paths), JSON.stringify(draft, null, 2) + "\n", "utf8");
302
+ }
303
+ export function clearDraftHandoff(paths) {
304
+ const filePath = draftHandoffFile(paths);
305
+ if (fs.existsSync(filePath)) {
306
+ fs.unlinkSync(filePath);
307
+ }
308
+ }
309
+ function createSession(agent, goal, title, plan) {
310
+ const timestamp = now();
311
+ return {
312
+ id: `session-${timestamp.replaceAll(":", "-").replaceAll(".", "-")}`,
313
+ agent,
314
+ branch: "",
315
+ startedAt: timestamp,
316
+ updatedAt: timestamp,
317
+ endedAt: null,
318
+ status: "active",
319
+ title: sanitizeText(title || goal || "Untitled session"),
320
+ currentGoal: sanitizeText(goal || "Capture work and prepare a clean handoff."),
321
+ currentPlan: sanitizeList(plan && plan.length ? plan : ["Read HOLISTIC.md", "Confirm next step with the user"]),
322
+ latestStatus: "Session started.",
323
+ workDone: [],
324
+ triedItems: [],
325
+ nextSteps: [],
326
+ assumptions: [],
327
+ blockers: [],
328
+ references: [],
329
+ impactNotes: [],
330
+ regressionRisks: [],
331
+ changedFiles: [],
332
+ checkpointCount: 0,
333
+ lastCheckpointReason: "session-start",
334
+ resumeRecap: [],
335
+ };
336
+ }
337
+ function uniqueMerge(current, incoming) {
338
+ const merged = [...current];
339
+ for (const item of incoming) {
340
+ if (!merged.includes(item)) {
341
+ merged.push(item);
342
+ }
343
+ }
344
+ return merged;
345
+ }
346
+ function recentFirstMerge(current, incoming) {
347
+ const incomingUnique = sanitizeList(incoming);
348
+ if (incomingUnique.length === 0) {
349
+ return [...current];
350
+ }
351
+ const remaining = current.filter((item) => !incomingUnique.includes(item));
352
+ return [...incomingUnique, ...remaining];
353
+ }
354
+ export function readArchivedSessions(paths) {
355
+ if (!fs.existsSync(paths.sessionsDir)) {
356
+ return [];
357
+ }
358
+ return fs.readdirSync(paths.sessionsDir)
359
+ .filter((file) => file.endsWith(".json"))
360
+ .map((file) => JSON.parse(fs.readFileSync(path.join(paths.sessionsDir, file), "utf8")))
361
+ .sort((left, right) => (right.endedAt || right.updatedAt).localeCompare(left.endedAt || left.updatedAt));
362
+ }
363
+ function buildResumeRecap(state) {
364
+ const lines = [];
365
+ if (state.activeSession) {
366
+ const session = state.activeSession;
367
+ lines.push(`Current objective: ${session.currentGoal}`);
368
+ lines.push(`Latest status: ${session.latestStatus}`);
369
+ if (session.triedItems.length > 0) {
370
+ lines.push(`Already tried: ${session.triedItems.join("; ")}`);
371
+ }
372
+ if (session.nextSteps.length > 0) {
373
+ lines.push(`Try next: ${session.nextSteps.join("; ")}`);
374
+ }
375
+ if (session.impactNotes.length > 0) {
376
+ lines.push(`Overall impact so far: ${session.impactNotes.join("; ")}`);
377
+ }
378
+ if (session.regressionRisks.length > 0) {
379
+ lines.push(`Regression watch: ${session.regressionRisks.join("; ")}`);
380
+ }
381
+ if (session.blockers.length > 0) {
382
+ lines.push(`Blockers: ${session.blockers.join("; ")}`);
383
+ }
384
+ if (state.pendingWork.length > 0) {
385
+ lines.push(`Pending work waiting in queue: ${state.pendingWork.length}`);
386
+ }
387
+ return lines;
388
+ }
389
+ if (state.lastHandoff) {
390
+ lines.push(`Last handoff summary: ${state.lastHandoff.summary}`);
391
+ lines.push(`Recommended next action: ${state.lastHandoff.nextAction}`);
392
+ if (state.lastHandoff.blockers.length > 0) {
393
+ lines.push(`Known blockers: ${state.lastHandoff.blockers.join("; ")}`);
394
+ }
395
+ }
396
+ if (state.pendingWork.length > 0) {
397
+ const top = state.pendingWork[0];
398
+ lines.push(`Top pending work: ${top.title}`);
399
+ lines.push(`Pending context: ${top.context}`);
400
+ lines.push(`Suggested next step: ${top.recommendedNextStep}`);
401
+ }
402
+ if (lines.length === 0) {
403
+ lines.push("No prior Holistic session history exists in this repo yet.");
404
+ }
405
+ return lines;
406
+ }
407
+ export function getResumePayload(state, agent) {
408
+ const recap = buildResumeRecap(state);
409
+ const hasCarryover = Boolean(state.activeSession || state.lastHandoff || state.pendingWork.length > 0);
410
+ const choices = hasCarryover ? ["continue", "tweak", "start-new"] : ["start-new"];
411
+ return {
412
+ status: hasCarryover ? "ready" : "empty",
413
+ recap,
414
+ choices,
415
+ recommendedCommand: hasCarryover ? "holistic resume --continue" : "holistic start-new --goal \"Describe the new task\"",
416
+ adapterDoc: state.docIndex.adapterDocs[agent] ?? state.docIndex.adapterDocs.codex,
417
+ activeSession: state.activeSession,
418
+ pendingWork: state.pendingWork,
419
+ lastHandoff: state.lastHandoff,
420
+ };
421
+ }
422
+ export function buildStartupGreeting(state, agent) {
423
+ const payload = getResumePayload(state, agent);
424
+ if (payload.status === "empty") {
425
+ return null;
426
+ }
427
+ const lines = [];
428
+ lines.push("Holistic resume");
429
+ lines.push("");
430
+ lines.push(...payload.recap.map((line) => `- ${line}`));
431
+ lines.push("");
432
+ lines.push(`Choices: ${payload.choices.join(", ")}`);
433
+ lines.push(`Adapter doc: ${payload.adapterDoc}`);
434
+ lines.push(`Recommended command: ${payload.recommendedCommand}`);
435
+ lines.push(`Long-term history: ${state.docIndex.historyDoc}`);
436
+ lines.push(`Regression watch: ${state.docIndex.regressionDoc}`);
437
+ lines.push(`Zero-touch architecture: ${state.docIndex.zeroTouchDoc}`);
438
+ return lines.join("\n");
439
+ }
440
+ export function loadSessionById(state, paths, sessionId) {
441
+ if (state.activeSession?.id === sessionId) {
442
+ return state.activeSession;
443
+ }
444
+ for (const session of readArchivedSessions(paths)) {
445
+ if (session.id === sessionId) {
446
+ return session;
447
+ }
448
+ }
449
+ return null;
450
+ }
451
+ export function computeSessionDiff(fromSession, toSession) {
452
+ return {
453
+ timeSpan: {
454
+ from: fromSession.startedAt,
455
+ to: toSession.startedAt,
456
+ durationMs: new Date(toSession.startedAt).getTime() - new Date(fromSession.startedAt).getTime(),
457
+ },
458
+ goalChanged: fromSession.currentGoal !== toSession.currentGoal,
459
+ fromGoal: fromSession.currentGoal,
460
+ toGoal: toSession.currentGoal,
461
+ newWork: toSession.workDone.filter((item) => !fromSession.workDone.includes(item)),
462
+ newRegressions: toSession.regressionRisks.filter((item) => !fromSession.regressionRisks.includes(item)),
463
+ clearedRegressions: fromSession.regressionRisks.filter((item) => !toSession.regressionRisks.includes(item)),
464
+ newBlockers: toSession.blockers.filter((item) => !fromSession.blockers.includes(item)),
465
+ clearedBlockers: fromSession.blockers.filter((item) => !toSession.blockers.includes(item)),
466
+ fileChanges: {
467
+ new: toSession.changedFiles.filter((item) => !fromSession.changedFiles.includes(item)),
468
+ removed: fromSession.changedFiles.filter((item) => !toSession.changedFiles.includes(item)),
469
+ },
470
+ };
471
+ }
472
+ function refreshSessionFromRepo(rootDir, state, session) {
473
+ const snapshot = getGitSnapshot(rootDir, state.repoSnapshot ?? {});
474
+ const changedFiles = snapshot.changedFiles.filter((file) => !isPortableHolisticFile(file));
475
+ return {
476
+ state: {
477
+ ...state,
478
+ repoSnapshot: snapshot.snapshot,
479
+ },
480
+ session: {
481
+ ...session,
482
+ branch: snapshot.branch,
483
+ changedFiles,
484
+ updatedAt: now(),
485
+ },
486
+ };
487
+ }
488
+ function syncActiveSession(state, goal, status, title, plan) {
489
+ if (!state.activeSession) {
490
+ return state;
491
+ }
492
+ const session = {
493
+ ...state.activeSession,
494
+ };
495
+ if (goal) {
496
+ session.currentGoal = sanitizeText(goal);
497
+ }
498
+ if (status) {
499
+ session.latestStatus = sanitizeText(status);
500
+ }
501
+ if (title) {
502
+ session.title = sanitizeText(title);
503
+ }
504
+ if (plan && plan.length > 0) {
505
+ session.currentPlan = sanitizeList(plan);
506
+ }
507
+ session.updatedAt = now();
508
+ session.resumeRecap = buildResumeRecap({
509
+ ...state,
510
+ activeSession: session,
511
+ });
512
+ return {
513
+ ...state,
514
+ activeSession: session,
515
+ };
516
+ }
517
+ export function checkpointState(rootDir, state, input) {
518
+ const agent = input.agent ?? state.activeSession?.agent ?? "unknown";
519
+ const baseSession = state.activeSession
520
+ ? state.activeSession
521
+ : createSession(agent, input.goal || "Capture work and prepare a clean handoff.", input.title, input.plan);
522
+ const refreshed = refreshSessionFromRepo(rootDir, state, baseSession);
523
+ const nextState = { ...refreshed.state };
524
+ const session = refreshed.session;
525
+ session.agent = agent;
526
+ if (input.goal) {
527
+ session.currentGoal = sanitizeText(input.goal);
528
+ }
529
+ if (input.title) {
530
+ session.title = sanitizeText(input.title);
531
+ }
532
+ if (input.plan && input.plan.length > 0) {
533
+ session.currentPlan = sanitizeList(input.plan);
534
+ }
535
+ if (input.status) {
536
+ session.latestStatus = sanitizeText(input.status);
537
+ }
538
+ session.workDone = uniqueMerge(session.workDone, sanitizeList(input.done));
539
+ session.triedItems = uniqueMerge(session.triedItems, sanitizeList(input.tried));
540
+ session.nextSteps = recentFirstMerge(session.nextSteps, input.next ?? []);
541
+ session.assumptions = uniqueMerge(session.assumptions, sanitizeList(input.assumptions));
542
+ session.blockers = uniqueMerge(session.blockers, sanitizeList(input.blockers));
543
+ session.references = uniqueMerge(session.references, sanitizeList(input.references));
544
+ session.impactNotes = uniqueMerge(session.impactNotes, sanitizeList(input.impacts));
545
+ session.regressionRisks = uniqueMerge(session.regressionRisks, sanitizeList(input.regressions));
546
+ if (input.impactsStructured) {
547
+ session.impactNotesStructured = input.impactsStructured;
548
+ }
549
+ if (input.regressionsStructured) {
550
+ session.regressionRisksStructured = input.regressionsStructured;
551
+ }
552
+ if (input.affectedAreas) {
553
+ session.affectedAreas = input.affectedAreas;
554
+ }
555
+ if (input.relatedSessions) {
556
+ session.relatedSessions = input.relatedSessions;
557
+ }
558
+ if (input.outcomeStatus) {
559
+ session.outcomeStatus = input.outcomeStatus;
560
+ }
561
+ if (input.severity) {
562
+ session.severity = input.severity;
563
+ }
564
+ session.lastCheckpointReason = sanitizeText(input.reason || "manual");
565
+ session.checkpointCount += 1;
566
+ session.resumeRecap = buildResumeRecap({
567
+ ...nextState,
568
+ activeSession: session,
569
+ });
570
+ return {
571
+ ...nextState,
572
+ activeSession: session,
573
+ pendingCommit: null,
574
+ };
575
+ }
576
+ function toPendingWork(session) {
577
+ return {
578
+ id: `pending-${session.id}`,
579
+ title: session.title,
580
+ context: session.latestStatus || session.currentGoal,
581
+ recommendedNextStep: session.nextSteps[0] || "Review HOLISTIC.md and decide the next concrete step.",
582
+ priority: session.blockers.length > 0 ? "high" : "medium",
583
+ carriedFromSession: session.id,
584
+ createdAt: now(),
585
+ };
586
+ }
587
+ function writeArchivedSession(paths, session) {
588
+ const filePath = path.join(paths.sessionsDir, `${session.id}.json`);
589
+ fs.writeFileSync(filePath, JSON.stringify(session, null, 2) + "\n", "utf8");
590
+ }
591
+ export function startNewSession(rootDir, state, agent, goal, plan, title) {
592
+ const nextState = { ...state, pendingWork: [...state.pendingWork], pendingCommit: null };
593
+ if (nextState.activeSession) {
594
+ const refreshed = refreshSessionFromRepo(rootDir, nextState, nextState.activeSession);
595
+ const archived = {
596
+ ...refreshed.session,
597
+ status: "superseded",
598
+ endedAt: now(),
599
+ };
600
+ writeArchivedSession(getRuntimePaths(rootDir), archived);
601
+ nextState.pendingWork.unshift(toPendingWork(archived));
602
+ nextState.repoSnapshot = refreshed.state.repoSnapshot;
603
+ }
604
+ nextState.activeSession = createSession(agent, goal, title, plan);
605
+ const refreshed = refreshSessionFromRepo(rootDir, nextState, nextState.activeSession);
606
+ nextState.activeSession = refreshed.session;
607
+ nextState.repoSnapshot = refreshed.state.repoSnapshot;
608
+ nextState.lastHandoff = null;
609
+ return nextState;
610
+ }
611
+ export function continueFromLatest(rootDir, state, agent) {
612
+ if (state.activeSession) {
613
+ const refreshed = refreshSessionFromRepo(rootDir, state, {
614
+ ...state.activeSession,
615
+ agent,
616
+ });
617
+ return {
618
+ ...refreshed.state,
619
+ activeSession: refreshed.session,
620
+ pendingCommit: null,
621
+ };
622
+ }
623
+ const inferred = inferSessionStart(rootDir, state);
624
+ const resumed = createSession(agent, inferred.goal, inferred.title, inferred.plan);
625
+ resumed.latestStatus = inferred.status;
626
+ resumed.nextSteps = inferred.nextSteps ? sanitizeList(inferred.nextSteps) : [];
627
+ resumed.blockers = inferred.blockers ? sanitizeList(inferred.blockers) : [];
628
+ const remainingPendingWork = inferred.consumePendingWork ? state.pendingWork.slice(1) : state.pendingWork;
629
+ const refreshed = refreshSessionFromRepo(rootDir, state, resumed);
630
+ refreshed.session.resumeRecap = buildResumeRecap({
631
+ ...refreshed.state,
632
+ activeSession: refreshed.session,
633
+ pendingWork: remainingPendingWork,
634
+ });
635
+ return {
636
+ ...refreshed.state,
637
+ activeSession: refreshed.session,
638
+ pendingWork: remainingPendingWork,
639
+ pendingCommit: null,
640
+ };
641
+ }
642
+ export function shouldAutoDraftHandoff(session, currentTimeMs = Date.now()) {
643
+ const updatedAtMs = new Date(session.updatedAt).getTime();
644
+ const startedAtMs = new Date(session.startedAt).getTime();
645
+ const idleMinutes = (currentTimeMs - updatedAtMs) / 60000;
646
+ if (idleMinutes >= 30) {
647
+ return { should: true, reason: "idle-30min" };
648
+ }
649
+ const sessionHours = (currentTimeMs - startedAtMs) / 3600000;
650
+ if (session.checkpointCount >= 5 && sessionHours >= 2) {
651
+ return { should: true, reason: "work-milestone" };
652
+ }
653
+ return { should: false, reason: "" };
654
+ }
655
+ export function buildAutoDraftHandoff(state, reason) {
656
+ const session = state.activeSession;
657
+ if (!session) {
658
+ return null;
659
+ }
660
+ return {
661
+ sourceSessionId: session.id,
662
+ sourceSessionUpdatedAt: session.updatedAt,
663
+ reason,
664
+ createdAt: now(),
665
+ handoff: {
666
+ summary: session.latestStatus || "Auto-drafted handoff",
667
+ done: [...session.workDone],
668
+ tried: [...session.triedItems],
669
+ next: session.nextSteps.length > 0 ? [...session.nextSteps] : ["Review auto-drafted handoff and continue."],
670
+ assumptions: [...session.assumptions],
671
+ blockers: [...session.blockers],
672
+ references: [...session.references],
673
+ impacts: [...session.impactNotes],
674
+ regressions: [...session.regressionRisks],
675
+ status: session.latestStatus,
676
+ },
677
+ };
678
+ }
679
+ export function applyHandoff(rootDir, state, input) {
680
+ if (!state.activeSession) {
681
+ return state;
682
+ }
683
+ const refreshed = refreshSessionFromRepo(rootDir, state, state.activeSession);
684
+ const session = refreshed.session;
685
+ session.status = "handed_off";
686
+ session.endedAt = now();
687
+ session.latestStatus = sanitizeText(input.status || session.latestStatus);
688
+ session.workDone = uniqueMerge(session.workDone, sanitizeList(input.done));
689
+ session.triedItems = uniqueMerge(session.triedItems, sanitizeList(input.tried));
690
+ session.nextSteps = recentFirstMerge(session.nextSteps, input.next ?? []);
691
+ session.assumptions = uniqueMerge(session.assumptions, sanitizeList(input.assumptions));
692
+ session.blockers = uniqueMerge(session.blockers, sanitizeList(input.blockers));
693
+ session.references = uniqueMerge(session.references, sanitizeList(input.references));
694
+ session.impactNotes = uniqueMerge(session.impactNotes, sanitizeList(input.impacts));
695
+ session.regressionRisks = uniqueMerge(session.regressionRisks, sanitizeList(input.regressions));
696
+ if (input.impactsStructured) {
697
+ session.impactNotesStructured = input.impactsStructured;
698
+ }
699
+ if (input.regressionsStructured) {
700
+ session.regressionRisksStructured = input.regressionsStructured;
701
+ }
702
+ if (input.affectedAreas) {
703
+ session.affectedAreas = input.affectedAreas;
704
+ }
705
+ if (input.relatedSessions) {
706
+ session.relatedSessions = input.relatedSessions;
707
+ }
708
+ if (input.outcomeStatus) {
709
+ session.outcomeStatus = input.outcomeStatus;
710
+ }
711
+ if (input.severity) {
712
+ session.severity = input.severity;
713
+ }
714
+ session.resumeRecap = buildResumeRecap({
715
+ ...refreshed.state,
716
+ activeSession: session,
717
+ });
718
+ const summary = sanitizeText(input.summary || session.latestStatus || session.currentGoal);
719
+ const nextAction = session.nextSteps[0] || "Review HOLISTIC.md and confirm the next action.";
720
+ const pendingWork = [...state.pendingWork];
721
+ if (session.nextSteps.length > 0 || session.blockers.length > 0) {
722
+ pendingWork.unshift(toPendingWork(session));
723
+ }
724
+ writeArchivedSession(getRuntimePaths(rootDir), session);
725
+ const paths = getRuntimePaths(rootDir);
726
+ return {
727
+ ...refreshed.state,
728
+ activeSession: null,
729
+ pendingWork,
730
+ lastHandoff: {
731
+ sessionId: session.id,
732
+ summary,
733
+ blockers: [...session.blockers],
734
+ nextAction,
735
+ committedAt: null,
736
+ createdAt: now(),
737
+ },
738
+ pendingCommit: {
739
+ message: `docs(holistic): handoff session ${session.id}`,
740
+ files: paths.trackedPaths,
741
+ },
742
+ };
743
+ }
744
+ //# sourceMappingURL=state.js.map