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.
- package/LICENSE +15 -0
- package/README.md +226 -0
- package/dist/adapters/claude/discover.js +91 -0
- package/dist/adapters/claude/index.js +18 -0
- package/dist/adapters/claude/parse.js +130 -0
- package/dist/adapters/claude/paths.js +26 -0
- package/dist/adapters/claude/resolve.js +34 -0
- package/dist/adapters/claude/shape.js +86 -0
- package/dist/adapters/codex/discover.js +27 -0
- package/dist/adapters/codex/index.js +18 -0
- package/dist/adapters/codex/paths.js +21 -0
- package/dist/adapters/codex/resolve.js +59 -0
- package/dist/adapters/codex/rollout.js +214 -0
- package/dist/cli.js +248 -0
- package/dist/core/doctor.js +187 -0
- package/dist/core/handoff.js +530 -0
- package/dist/core/history.js +117 -0
- package/dist/core/project-status.js +167 -0
- package/dist/core/redact.js +21 -0
- package/dist/core/schema.js +1 -0
- package/dist/core/state.js +444 -0
- package/dist/session-loader.js +54 -0
- package/package.json +58 -0
|
@@ -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
|
+
}
|