hammadev 0.1.0-alpha.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.
@@ -0,0 +1,530 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import pc from "picocolors";
5
+ import { extractTaskState, getMessageImportance, HANDOFF_SCHEMA_VERSION, } from "./state.js";
6
+ const HANDOFF_TARGET_BYTES = 15 * 1024;
7
+ const HANDOFF_HARD_MAX_BYTES = 20 * 1024;
8
+ const TIMELINE_MAX_ENTRIES = 50;
9
+ const TIMELINE_MAX_ENTRY_CHARS = 800;
10
+ const CLI_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
11
+ function assertCliName(value, label) {
12
+ if (!CLI_NAME.test(value)) {
13
+ throw new Error(`Invalid ${label} CLI name '${value}'. Use 1-64 letters, numbers, underscores, or hyphens, starting with a letter or number.`);
14
+ }
15
+ }
16
+ function assertPathWithin(parent, candidate) {
17
+ const relative = path.relative(parent, candidate);
18
+ if (!relative ||
19
+ relative === ".." ||
20
+ relative.startsWith(".." + path.sep) ||
21
+ path.isAbsolute(relative)) {
22
+ throw new Error(`Refusing handoff path outside ${parent}: ${candidate}`);
23
+ }
24
+ }
25
+ async function assertDirectoryNotSymlink(directory, label) {
26
+ const stats = await fs.lstat(directory);
27
+ if (stats.isSymbolicLink()) {
28
+ throw new Error(`Cannot create handoff: ${label} must not be a symbolic link (${directory}).`);
29
+ }
30
+ if (!stats.isDirectory()) {
31
+ throw new Error(`Cannot create handoff: ${label} is not a directory (${directory}).`);
32
+ }
33
+ }
34
+ async function validateProjectPath(projectPath) {
35
+ if (!path.isAbsolute(projectPath)) {
36
+ throw new Error(`Cannot create handoff: projectPath must be absolute (${projectPath}).`);
37
+ }
38
+ const resolved = path.resolve(projectPath);
39
+ try {
40
+ await assertDirectoryNotSymlink(resolved, "projectPath");
41
+ const canonical = await fs.realpath(resolved);
42
+ if (canonical !== resolved) {
43
+ throw new Error(`Cannot create handoff: projectPath contains symbolic-link components (${projectPath}).`);
44
+ }
45
+ return canonical;
46
+ }
47
+ catch (error) {
48
+ if (error.message?.startsWith("Cannot create handoff:"))
49
+ throw error;
50
+ throw new Error(`Cannot create handoff: invalid projectPath '${projectPath}': ${error.message}`);
51
+ }
52
+ }
53
+ async function prepareTasksRoot(projectPath) {
54
+ const hammaRoot = path.join(projectPath, ".hamma");
55
+ const tasksRoot = path.join(hammaRoot, "tasks");
56
+ assertPathWithin(projectPath, tasksRoot);
57
+ for (const [directory, label] of [
58
+ [hammaRoot, ".hamma directory"],
59
+ [tasksRoot, ".hamma/tasks directory"],
60
+ ]) {
61
+ try {
62
+ await fs.mkdir(directory);
63
+ }
64
+ catch (error) {
65
+ if (error.code !== "EEXIST") {
66
+ throw new Error(`Cannot create ${label}: ${error.message}`);
67
+ }
68
+ }
69
+ await assertDirectoryNotSymlink(directory, label);
70
+ }
71
+ const canonicalTasksRoot = await fs.realpath(tasksRoot);
72
+ if (canonicalTasksRoot !== tasksRoot) {
73
+ throw new Error("Cannot create handoff: .hamma/tasks contains symbolic-link components.");
74
+ }
75
+ assertPathWithin(projectPath, canonicalTasksRoot);
76
+ return canonicalTasksRoot;
77
+ }
78
+ function truncate(s, max) {
79
+ if (!s)
80
+ return "";
81
+ const t = s.trim();
82
+ if (t.length <= max)
83
+ return t;
84
+ return t.slice(0, max).trimEnd() + "…";
85
+ }
86
+ function firstParagraph(text, max) {
87
+ const t = text.trim();
88
+ const block = t.split(/\n\s*\n/)[0] ?? t;
89
+ return truncate(block, max);
90
+ }
91
+ function computeRepoState(projectPath) {
92
+ const warnings = [];
93
+ if (!projectPath) {
94
+ warnings.push("No project path available in session metadata.");
95
+ return { warnings };
96
+ }
97
+ const run = (cmd) => {
98
+ try {
99
+ const out = execSync(cmd, {
100
+ cwd: projectPath,
101
+ encoding: "utf8",
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ maxBuffer: 4 * 1024 * 1024,
104
+ }).toString();
105
+ return out.trim();
106
+ }
107
+ catch (err) {
108
+ warnings.push(`\`${cmd}\` failed: ${err.message?.split("\n")[0] ?? "unknown error"}`);
109
+ return undefined;
110
+ }
111
+ };
112
+ let gitStatusShort = run("git status --short");
113
+ if (gitStatusShort !== undefined) {
114
+ const lines = gitStatusShort.split("\n");
115
+ if (lines.length > 100) {
116
+ gitStatusShort = lines.slice(0, 100).join("\n") + `\n… (${lines.length - 100} more)`;
117
+ }
118
+ }
119
+ const gitDiffStat = run("git diff --stat");
120
+ return { gitStatusShort, gitDiffStat, warnings };
121
+ }
122
+ async function ensureGitignore(projectPath) {
123
+ const gitignorePath = path.join(projectPath, ".gitignore");
124
+ const entry = "\n# Hamma local agent handoff artifacts\n.hamma/\n";
125
+ try {
126
+ const stats = await fs.lstat(gitignorePath).catch((error) => {
127
+ if (error.code === "ENOENT")
128
+ return undefined;
129
+ throw error;
130
+ });
131
+ if (stats?.isSymbolicLink()) {
132
+ console.warn(pc.yellow(`Warning: Refusing to update symbolic-link .gitignore: ${gitignorePath}`));
133
+ return;
134
+ }
135
+ const content = await fs.readFile(gitignorePath, "utf8");
136
+ if (!content.includes(".hamma/")) {
137
+ await fs.appendFile(gitignorePath, entry, "utf8");
138
+ }
139
+ }
140
+ catch (err) {
141
+ if (err.code === "ENOENT") {
142
+ await fs.writeFile(gitignorePath, entry.trimStart(), "utf8");
143
+ }
144
+ else {
145
+ console.warn(pc.yellow(`Warning: Could not check/update .gitignore: ${err.message}`));
146
+ }
147
+ }
148
+ }
149
+ function fmtCodeBlock(lang, body, empty = "(none)") {
150
+ const inner = body && body.length > 0 ? body : empty;
151
+ return "```" + lang + "\n" + inner + "\n```";
152
+ }
153
+ function taskLine(t) {
154
+ const id = t.id ? `#${t.id}` : "?";
155
+ const title = t.title ?? firstParagraph(t.summary, 160);
156
+ return `- **Task ${id}** — ${truncate(title, 200)}`;
157
+ }
158
+ function buildCurrentStateSummary(state) {
159
+ const completed = state.tasks.filter((t) => t.status === "completed").map((t) => t.id).filter(Boolean);
160
+ const remaining = state.tasks.filter((t) => t.status === "remaining").map((t) => t.id).filter(Boolean);
161
+ const parts = [];
162
+ if (completed.length > 0) {
163
+ parts.push(`${completed.length} task${completed.length === 1 ? "" : "s"} completed (${formatIdList(completed)})`);
164
+ }
165
+ if (remaining.length > 0) {
166
+ parts.push(`${remaining.length} task${remaining.length === 1 ? "" : "s"} remaining (${formatIdList(remaining)})`);
167
+ }
168
+ const summary = parts.length ? parts.join(". ") + "." : "No task ledger detected.";
169
+ const status = state.current.latestAssistantStatus
170
+ ? "\n\nLatest source-agent status:\n> " + truncate(state.current.latestAssistantStatus, 500).replace(/\n/g, "\n> ")
171
+ : "";
172
+ return summary + status;
173
+ }
174
+ function formatIdList(ids) {
175
+ const nums = ids
176
+ .map((s) => Number(s))
177
+ .filter((n) => Number.isFinite(n))
178
+ .sort((a, b) => a - b);
179
+ if (nums.length === 0)
180
+ return ids.join(", ");
181
+ const ranges = [];
182
+ let start = nums[0];
183
+ let prev = nums[0];
184
+ for (let i = 1; i < nums.length; i++) {
185
+ const n = nums[i];
186
+ if (n === prev + 1) {
187
+ prev = n;
188
+ continue;
189
+ }
190
+ ranges.push(start === prev ? `#${start}` : `#${start}–#${prev}`);
191
+ start = n;
192
+ prev = n;
193
+ }
194
+ ranges.push(start === prev ? `#${start}` : `#${start}–#${prev}`);
195
+ return ranges.join(", ");
196
+ }
197
+ function renderHandoffMarkdown(state, opts) {
198
+ const { goal, project, current, tasks, verification, risks, repoState, } = state;
199
+ const completed = tasks.filter((t) => t.status === "completed");
200
+ const remaining = tasks.filter((t) => t.status === "remaining" || t.status === "in_progress" || t.status === "blocked");
201
+ const nextAction = current.nextRecommendedTask ??
202
+ (remaining[0]
203
+ ? `Task #${remaining[0].id}: ${truncate(remaining[0].title ?? remaining[0].summary, 200)}`
204
+ : current.latestUserInstruction ?? "Continue from the source agent's last state.");
205
+ const sections = [];
206
+ sections.push(`# Hamma Handoff`);
207
+ sections.push(`## Continue from here\n${truncate(nextAction, 500)}`);
208
+ sections.push(`## Current state\n${buildCurrentStateSummary(state)}`);
209
+ if (goal) {
210
+ sections.push(`## Original goal\n> ${truncate(goal, 400).replace(/\n/g, "\n> ")}`);
211
+ }
212
+ sections.push([
213
+ `## Source`,
214
+ `- Source CLI: ${project.sourceCli}`,
215
+ `- Target CLI: ${project.targetCli}`,
216
+ `- Artifact schema version: ${HANDOFF_SCHEMA_VERSION}`,
217
+ `- Source session ID: ${project.sourceSessionId ?? "unknown"}`,
218
+ `- Project path: ${project.path ?? "unknown"}`,
219
+ `- Source rollout path: ${project.sourcePath ?? "unknown"}`,
220
+ `- Started at: ${project.startedAt ?? "unknown"}`,
221
+ `- Last updated: ${project.lastUpdatedAt ?? "unknown"}`,
222
+ ].join("\n"));
223
+ const completedBlock = completed.length
224
+ ? completed.map((t) => taskLine(t)).join("\n")
225
+ : "(none detected)";
226
+ sections.push(`## Completed work\n${completedBlock}`);
227
+ const remainingBlock = remaining.length
228
+ ? remaining.map((t) => taskLine(t)).join("\n")
229
+ : current.latestUserInstruction
230
+ ? `- ${truncate(current.latestUserInstruction, 240)}`
231
+ : "(none detected)";
232
+ sections.push(`## Remaining work\n${remainingBlock}`);
233
+ const verificationList = verification.slice(0, opts.compact ? 8 : 16);
234
+ const verificationBlock = verificationList.length
235
+ ? verificationList.map((v) => `- ${v}`).join("\n")
236
+ : "(no explicit verification signals extracted)";
237
+ sections.push(`## Verification\n${verificationBlock}`);
238
+ const gitBlock = [
239
+ `### \`git status --short\``,
240
+ fmtCodeBlock("", repoState.gitStatusShort, "(clean)"),
241
+ `### \`git diff --stat\``,
242
+ fmtCodeBlock("", repoState.gitDiffStat, "(no unstaged changes)"),
243
+ ];
244
+ if (repoState.warnings.length) {
245
+ gitBlock.push(`Warnings:\n${repoState.warnings.map((w) => `- ${w}`).join("\n")}`);
246
+ }
247
+ sections.push(`## Current repo state\n${gitBlock.join("\n")}`);
248
+ const riskList = risks.slice(0, opts.compact ? 8 : 20);
249
+ const risksBlock = riskList.length ? riskList.map((r) => `- ${r}`).join("\n") : "(none detected)";
250
+ sections.push(`## Known risks\n${risksBlock}`);
251
+ sections.push([
252
+ `## Safety notes`,
253
+ `- Sensitive values may have been redacted.`,
254
+ `- Internal/system/developer context was omitted from the handoff.`,
255
+ `- Native CLI session files were not modified.`,
256
+ ].join("\n"));
257
+ sections.push([
258
+ `## References`,
259
+ `- Full normalized session: session.json`,
260
+ `- Structured state: state.json`,
261
+ `- Compact timeline: timeline.md`,
262
+ `- Command summary: commands.md`,
263
+ `- Redaction report: redaction-report.md`,
264
+ ].join("\n"));
265
+ return sections.join("\n\n") + "\n";
266
+ }
267
+ function classifyTimelineImportance(msg) {
268
+ return getMessageImportance(msg);
269
+ }
270
+ function renderTimelineMarkdown(session) {
271
+ const messages = session.messages.filter((m) => m.role !== "system");
272
+ const entries = messages.map((m) => ({
273
+ timestamp: m.timestamp,
274
+ role: m.role,
275
+ content: truncate(firstParagraph(m.content, TIMELINE_MAX_ENTRY_CHARS), TIMELINE_MAX_ENTRY_CHARS),
276
+ importance: classifyTimelineImportance(m),
277
+ }));
278
+ const kept = entries.filter((e) => e.importance !== "low");
279
+ let selected = kept;
280
+ let dropped = entries.length - kept.length;
281
+ if (selected.length > TIMELINE_MAX_ENTRIES) {
282
+ dropped += selected.length - TIMELINE_MAX_ENTRIES;
283
+ const high = selected.filter((e) => e.importance === "high");
284
+ if (high.length <= TIMELINE_MAX_ENTRIES) {
285
+ const mediums = selected.filter((e) => e.importance === "medium");
286
+ const room = TIMELINE_MAX_ENTRIES - high.length;
287
+ const mediumTail = mediums.slice(-room);
288
+ const mediumSet = new Set(mediumTail);
289
+ selected = selected.filter((e) => e.importance === "high" || mediumSet.has(e));
290
+ }
291
+ else {
292
+ selected = high.slice(-TIMELINE_MAX_ENTRIES);
293
+ }
294
+ }
295
+ const body = selected
296
+ .map((e) => {
297
+ const ts = e.timestamp ?? "unknown-time";
298
+ const role = e.role.toUpperCase();
299
+ return `### ${role} — ${ts}\n${e.content}`;
300
+ })
301
+ .join("\n\n");
302
+ const footer = dropped > 0
303
+ ? `\n\n---\n\n${dropped} lower-importance events omitted. See session.json for full archive.\n`
304
+ : "\n";
305
+ return `# Timeline\n\n${body}${footer}`;
306
+ }
307
+ function extractExecCmd(raw) {
308
+ const m = raw.match(/exec_command\(\s*\{[\s\S]*?\bcmd\s*:\s*"((?:[^"\\]|\\.)*)"/);
309
+ if (!m)
310
+ return undefined;
311
+ return m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\").replace(/\\n/g, "\n");
312
+ }
313
+ function classifyShell(inner) {
314
+ let trimmed = inner.trim();
315
+ while (/^[A-Z_][A-Z0-9_]*=\S+\s+/.test(trimmed) ||
316
+ /^\S*\[REDACTED_SECRET\]\S*\s+/.test(trimmed)) {
317
+ trimmed = trimmed.replace(/^\S+\s+/, "");
318
+ }
319
+ const tokens = trimmed.split(/\s+/);
320
+ const first = tokens[0] ?? "";
321
+ const firstTwo = tokens.slice(0, 2).join(" ");
322
+ const firstThree = tokens.slice(0, 3).join(" ");
323
+ if (/^(npm|pnpm|yarn)$/.test(first)) {
324
+ return { label: firstThree || firstTwo, category: "verification", count: 0 };
325
+ }
326
+ if (first === "npx") {
327
+ return { label: firstThree || firstTwo, category: "verification", count: 0 };
328
+ }
329
+ if (first === "git") {
330
+ return { label: firstTwo, category: "repo", count: 0 };
331
+ }
332
+ if (/^(rg|grep|ag|ack|find|ls|sed|awk|cat|head|tail|wc|jq)$/.test(first)) {
333
+ return { label: first, category: "repo", count: 0, note: "repo inspection" };
334
+ }
335
+ if (first === "node" || first === "tsx" || first === "ts-node") {
336
+ return { label: firstTwo, category: "other", count: 0 };
337
+ }
338
+ return { label: first || "shell", category: "other", count: 0 };
339
+ }
340
+ function classifyCommand(raw) {
341
+ const shellInner = extractExecCmd(raw);
342
+ if (shellInner)
343
+ return classifyShell(shellInner);
344
+ const mcpPlaywright = raw.match(/tools\.mcp__playwright__([a-zA-Z_]+)/);
345
+ if (mcpPlaywright) {
346
+ return { label: `playwright.${mcpPlaywright[1]}`, category: "browser", count: 0 };
347
+ }
348
+ const mcpOther = raw.match(/tools\.mcp__([a-zA-Z_]+)__([a-zA-Z_]+)/);
349
+ if (mcpOther) {
350
+ return { label: `mcp.${mcpOther[1]}.${mcpOther[2]}`, category: "wrapper", count: 0 };
351
+ }
352
+ const toolCall = raw.match(/tools\.([a-zA-Z_]+)/);
353
+ if (toolCall) {
354
+ return { label: `tools.${toolCall[1]}`, category: "wrapper", count: 0 };
355
+ }
356
+ const first = raw.trim().split(/\s+/)[0] ?? "unknown";
357
+ return { label: first, category: "other", count: 0 };
358
+ }
359
+ function summarizeOutcome(cmd) {
360
+ if (typeof cmd.exitCode === "number")
361
+ return `exit ${cmd.exitCode}`;
362
+ if (!cmd.output)
363
+ return undefined;
364
+ const m = cmd.output.match(/"exit_code"\s*:\s*(-?\d+)/);
365
+ if (m)
366
+ return `exit ${m[1]}`;
367
+ return undefined;
368
+ }
369
+ function renderCommandsMarkdown(session) {
370
+ const buckets = new Map();
371
+ for (const cmd of session.shellCommands) {
372
+ const bucket = classifyCommand(cmd.command);
373
+ const key = `${bucket.category}::${bucket.label}`;
374
+ const existing = buckets.get(key) ?? { ...bucket };
375
+ existing.count += 1;
376
+ const outcome = summarizeOutcome(cmd);
377
+ if (outcome)
378
+ existing.latestOutcome = outcome;
379
+ buckets.set(key, existing);
380
+ }
381
+ const all = Array.from(buckets.values()).sort((a, b) => b.count - a.count);
382
+ const byCategory = {
383
+ verification: [],
384
+ repo: [],
385
+ browser: [],
386
+ other: [],
387
+ wrapper: [],
388
+ };
389
+ for (const b of all)
390
+ byCategory[b.category].push(b);
391
+ const section = (title, items) => {
392
+ if (items.length === 0)
393
+ return "";
394
+ const lines = items.map((b) => {
395
+ const outcome = b.latestOutcome ? ` — latest: ${b.latestOutcome}` : "";
396
+ const note = b.note ? ` (${b.note})` : "";
397
+ return `- \`${b.label}\` — ${b.count}×${outcome}${note}`;
398
+ });
399
+ return `## ${title}\n${lines.join("\n")}`;
400
+ };
401
+ const parts = [
402
+ `# Commands`,
403
+ `Total observed shell/tool invocations: ${session.shellCommands.length}.`,
404
+ section("Verification & build", byCategory.verification),
405
+ section("Repo inspection", byCategory.repo),
406
+ section("Browser / Playwright verification", byCategory.browser),
407
+ section("Other shell", byCategory.other),
408
+ section("Wrapper calls (down-ranked)", byCategory.wrapper),
409
+ `\n> Raw outputs and per-invocation details are omitted from this summary. See session.json for full archive.`,
410
+ ].filter(Boolean);
411
+ return parts.join("\n\n") + "\n";
412
+ }
413
+ function renderRedactionReport(session) {
414
+ return [
415
+ "# Redaction Report",
416
+ `Total redactions: ${session.security.redactionCount}`,
417
+ `Has redactions: ${session.security.redacted}`,
418
+ "",
419
+ "Warnings:",
420
+ ...(session.security.warnings.length
421
+ ? session.security.warnings.map((w) => `- ${w}`)
422
+ : ["- None"]),
423
+ "",
424
+ ].join("\n");
425
+ }
426
+ function toCompactState(state) {
427
+ const trimTask = (t) => ({
428
+ id: t.id,
429
+ title: t.title,
430
+ status: t.status,
431
+ summary: truncate(t.title ?? t.summary, 200),
432
+ evidence: [],
433
+ risks: t.risks.slice(0, 1),
434
+ filesMentioned: t.filesMentioned.slice(0, 2),
435
+ });
436
+ return {
437
+ ...state,
438
+ tasks: state.tasks.map(trimTask),
439
+ verification: state.verification.slice(0, 6),
440
+ risks: state.risks.slice(0, 6),
441
+ filesMentioned: state.filesMentioned.slice(0, 10),
442
+ };
443
+ }
444
+ function renderHandoffWithSizeGuard(state) {
445
+ let md = renderHandoffMarkdown(state, { compact: false });
446
+ if (Buffer.byteLength(md, "utf8") <= HANDOFF_TARGET_BYTES)
447
+ return md;
448
+ md = renderHandoffMarkdown(state, { compact: true });
449
+ if (Buffer.byteLength(md, "utf8") <= HANDOFF_TARGET_BYTES)
450
+ return md;
451
+ md = renderHandoffMarkdown(toCompactState(state), { compact: true });
452
+ if (Buffer.byteLength(md, "utf8") <= HANDOFF_HARD_MAX_BYTES)
453
+ return md;
454
+ const cap = HANDOFF_HARD_MAX_BYTES - 400;
455
+ const buf = Buffer.from(md, "utf8");
456
+ if (buf.byteLength <= cap)
457
+ return md;
458
+ const truncated = buf.slice(0, cap).toString("utf8");
459
+ return truncated + "\n\n> Content truncated to respect handoff size limit. See timeline.md and state.json for the full picture.\n";
460
+ }
461
+ export async function createHandoff(session, targetCli, useGitignore = true) {
462
+ if (!session.meta.projectPath) {
463
+ throw new Error("Cannot create handoff: source session has no projectPath.");
464
+ }
465
+ assertCliName(targetCli, "target");
466
+ assertCliName(session.meta.sourceCli, "source");
467
+ const projectPath = await validateProjectPath(session.meta.projectPath);
468
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
469
+ const taskId = `${timestamp}-${session.meta.sourceCli}-to-${targetCli}`;
470
+ const tasksRoot = await prepareTasksRoot(projectPath);
471
+ const finalDir = path.join(tasksRoot, taskId);
472
+ const tempDir = path.join(tasksRoot, `.tmp-${taskId}`);
473
+ assertPathWithin(tasksRoot, finalDir);
474
+ assertPathWithin(tasksRoot, tempDir);
475
+ if (useGitignore) {
476
+ await ensureGitignore(projectPath);
477
+ }
478
+ const repoState = computeRepoState(projectPath);
479
+ const state = extractTaskState(session, { targetCli, repoState });
480
+ let tempCreated = false;
481
+ try {
482
+ try {
483
+ await fs.lstat(finalDir);
484
+ throw new Error(`Handoff task directory already exists: ${finalDir}`);
485
+ }
486
+ catch (error) {
487
+ if (error.code !== "ENOENT")
488
+ throw error;
489
+ }
490
+ try {
491
+ await fs.mkdir(tempDir);
492
+ tempCreated = true;
493
+ }
494
+ catch (error) {
495
+ if (error.code === "EEXIST") {
496
+ throw new Error(`Temporary handoff directory already exists; remove it before retrying: ${tempDir}`);
497
+ }
498
+ throw error;
499
+ }
500
+ await fs.writeFile(path.join(tempDir, "session.json"), JSON.stringify(session, null, 2), "utf8");
501
+ await fs.writeFile(path.join(tempDir, "state.json"), JSON.stringify(state, null, 2), "utf8");
502
+ await fs.writeFile(path.join(tempDir, "redaction-report.md"), renderRedactionReport(session), "utf8");
503
+ await fs.writeFile(path.join(tempDir, "timeline.md"), renderTimelineMarkdown(session), "utf8");
504
+ await fs.writeFile(path.join(tempDir, "commands.md"), renderCommandsMarkdown(session), "utf8");
505
+ await fs.writeFile(path.join(tempDir, "handoff.md"), renderHandoffWithSizeGuard(state), "utf8");
506
+ await fs.rename(tempDir, finalDir);
507
+ tempCreated = false;
508
+ }
509
+ catch (error) {
510
+ if (tempCreated) {
511
+ await fs
512
+ .rm(tempDir, { recursive: true, force: true })
513
+ .catch(() => undefined);
514
+ }
515
+ if (error.code === "EEXIST" || error.code === "ENOTEMPTY") {
516
+ throw new Error(`Handoff task directory already exists: ${finalDir}`);
517
+ }
518
+ throw error;
519
+ }
520
+ const handoffPath = path.join(finalDir, "handoff.md");
521
+ const relativeHandoffPath = path.relative(projectPath, handoffPath);
522
+ const relTaskDir = path.dirname(relativeHandoffPath);
523
+ console.log(pc.green("Handoff created at:"));
524
+ console.log(pc.dim(`Absolute: ${handoffPath}`));
525
+ console.log(pc.dim(`Relative: ${relativeHandoffPath}`));
526
+ console.log("");
527
+ console.log(pc.bold("Suggested command:"));
528
+ console.log(pc.cyan(`cd ${projectPath}`));
529
+ console.log(pc.cyan(`${targetCli} "Read ${relTaskDir}/handoff.md and continue the task from the current repo state."`));
530
+ }
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const TASK_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})(?:-(\d{3}))?Z(?:-|$)/;
4
+ function timestampFromTaskId(taskId) {
5
+ const match = taskId.match(TASK_TIMESTAMP);
6
+ if (!match)
7
+ return undefined;
8
+ const [, year, month, day, hour, minute, second, millis = "000"] = match;
9
+ const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
10
+ return Number.isNaN(date.getTime()) ? undefined : date;
11
+ }
12
+ function field(markdown, label) {
13
+ const match = markdown.match(new RegExp(`^- ${label}:\\s*(.+)$`, "mi"));
14
+ return match?.[1]?.trim();
15
+ }
16
+ function continueFromHere(markdown) {
17
+ const match = markdown.match(/^## Continue from here\s*\n+([\s\S]*?)(?=\n##\s|$)/mi);
18
+ if (!match)
19
+ return undefined;
20
+ const line = match[1]
21
+ .split("\n")
22
+ .map((value) => value.trim())
23
+ .find(Boolean);
24
+ return line || undefined;
25
+ }
26
+ function tasksPath(projectPath) {
27
+ return path.join(path.resolve(projectPath), ".hamma", "tasks");
28
+ }
29
+ function assertTaskId(taskId) {
30
+ if (!taskId ||
31
+ taskId === "." ||
32
+ taskId === ".." ||
33
+ path.basename(taskId) !== taskId ||
34
+ taskId.includes("/") ||
35
+ taskId.includes("\\")) {
36
+ throw new Error(`Invalid handoff task id: ${taskId}`);
37
+ }
38
+ }
39
+ export async function listHandoffs(projectPath) {
40
+ const root = tasksPath(projectPath);
41
+ let entries;
42
+ try {
43
+ entries = await fs.readdir(root, { withFileTypes: true });
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ return [];
48
+ throw error;
49
+ }
50
+ const handoffs = await Promise.all(entries
51
+ .filter((entry) => entry.isDirectory())
52
+ .map(async (entry) => {
53
+ const handoffPath = path.join(root, entry.name, "handoff.md");
54
+ try {
55
+ const [markdown, stats] = await Promise.all([
56
+ fs.readFile(handoffPath, "utf8"),
57
+ fs.stat(path.join(root, entry.name)),
58
+ ]);
59
+ const created = timestampFromTaskId(entry.name) ?? stats.birthtime ?? stats.mtime;
60
+ return {
61
+ taskId: entry.name,
62
+ sourceAgent: field(markdown, "Source CLI") ?? "unknown",
63
+ targetAgent: field(markdown, "Target CLI") ?? "unknown",
64
+ createdAt: created.toISOString(),
65
+ handoffPath,
66
+ continueFromHere: continueFromHere(markdown),
67
+ };
68
+ }
69
+ catch (error) {
70
+ if (error.code === "ENOENT" || error.code === "EISDIR")
71
+ return undefined;
72
+ throw error;
73
+ }
74
+ }));
75
+ return handoffs
76
+ .filter((entry) => entry !== undefined)
77
+ .sort((a, b) => {
78
+ const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
79
+ return byTime || b.taskId.localeCompare(a.taskId);
80
+ });
81
+ }
82
+ export async function readHandoff(projectPath, taskId) {
83
+ const resolvedTaskId = taskId === "latest"
84
+ ? (await listHandoffs(projectPath))[0]?.taskId
85
+ : taskId;
86
+ if (!resolvedTaskId) {
87
+ throw new Error(`No handoffs found in ${tasksPath(projectPath)}.`);
88
+ }
89
+ assertTaskId(resolvedTaskId);
90
+ const handoffPath = path.join(tasksPath(projectPath), resolvedTaskId, "handoff.md");
91
+ try {
92
+ return await fs.readFile(handoffPath, "utf8");
93
+ }
94
+ catch (error) {
95
+ if (error.code === "ENOENT") {
96
+ throw new Error(`Handoff '${resolvedTaskId}' was not found in ${tasksPath(projectPath)}.`);
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ export function formatHandoffLog(entries) {
102
+ return entries
103
+ .map((entry) => {
104
+ const lines = [
105
+ `Task: ${entry.taskId}`,
106
+ ` Source agent: ${entry.sourceAgent}`,
107
+ ` Target agent: ${entry.targetAgent}`,
108
+ ` Created: ${entry.createdAt}`,
109
+ ` Handoff: ${entry.handoffPath}`,
110
+ ];
111
+ if (entry.continueFromHere) {
112
+ lines.push(` Continue from here: ${entry.continueFromHere}`);
113
+ }
114
+ return lines.join("\n");
115
+ })
116
+ .join("\n\n");
117
+ }