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,167 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { discoverClaudeSessions } from "../adapters/claude/discover.js";
|
|
6
|
+
import { discoverCodexSessions } from "../adapters/codex/discover.js";
|
|
7
|
+
import { listHandoffs } from "./history.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
function safeMetadata(value) {
|
|
10
|
+
if (typeof value !== "string")
|
|
11
|
+
return undefined;
|
|
12
|
+
const sanitized = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
13
|
+
return sanitized ? sanitized.slice(0, 80) : undefined;
|
|
14
|
+
}
|
|
15
|
+
async function inspectGit(projectPath) {
|
|
16
|
+
try {
|
|
17
|
+
const repository = await execFileAsync("git", ["-C", projectPath, "rev-parse", "--is-inside-work-tree"], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
20
|
+
});
|
|
21
|
+
if (repository.stdout.trim() !== "true") {
|
|
22
|
+
return {
|
|
23
|
+
isGitRepo: false,
|
|
24
|
+
gitStatus: "not-a-repository",
|
|
25
|
+
hammaIgnored: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return {
|
|
31
|
+
isGitRepo: false,
|
|
32
|
+
gitStatus: error.code === "ENOENT" ? "unavailable" : "not-a-repository",
|
|
33
|
+
hammaIgnored: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
let gitStatus = "unavailable";
|
|
37
|
+
try {
|
|
38
|
+
const status = await execFileAsync("git", ["-C", projectPath, "status", "--porcelain"], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
41
|
+
});
|
|
42
|
+
gitStatus = status.stdout.trim() ? "dirty" : "clean";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Keep the explicit unavailable state when status cannot be read.
|
|
46
|
+
}
|
|
47
|
+
let hammaIgnored = false;
|
|
48
|
+
try {
|
|
49
|
+
await execFileAsync("git", [
|
|
50
|
+
"-C",
|
|
51
|
+
projectPath,
|
|
52
|
+
"check-ignore",
|
|
53
|
+
"-q",
|
|
54
|
+
"--no-index",
|
|
55
|
+
"--",
|
|
56
|
+
".hamma/status-probe",
|
|
57
|
+
], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
60
|
+
});
|
|
61
|
+
hammaIgnored = true;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 1)
|
|
65
|
+
hammaIgnored = false;
|
|
66
|
+
}
|
|
67
|
+
return { isGitRepo: true, gitStatus, hammaIgnored };
|
|
68
|
+
}
|
|
69
|
+
async function countTaskDirectories(projectPath) {
|
|
70
|
+
const tasksPath = path.join(projectPath, ".hamma", "tasks");
|
|
71
|
+
try {
|
|
72
|
+
const entries = await fs.readdir(tasksPath, { withFileTypes: true });
|
|
73
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".tmp-")).length;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "ENOENT")
|
|
77
|
+
return 0;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function routeFromState(latest) {
|
|
82
|
+
try {
|
|
83
|
+
const statePath = path.join(path.dirname(latest.handoffPath), "state.json");
|
|
84
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"));
|
|
85
|
+
return {
|
|
86
|
+
sourceAgent: safeMetadata(state?.project?.sourceCli ?? state?.sourceCli),
|
|
87
|
+
targetAgent: safeMetadata(state?.project?.targetCli ?? state?.targetCli),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function latestHandoffStatus(handoffs) {
|
|
95
|
+
const latest = handoffs[0];
|
|
96
|
+
if (!latest)
|
|
97
|
+
return undefined;
|
|
98
|
+
const stateRoute = latest.sourceAgent === "unknown" || latest.targetAgent === "unknown"
|
|
99
|
+
? await routeFromState(latest)
|
|
100
|
+
: {};
|
|
101
|
+
return {
|
|
102
|
+
taskId: latest.taskId,
|
|
103
|
+
path: latest.handoffPath,
|
|
104
|
+
sourceAgent: safeMetadata(latest.sourceAgent) === "unknown"
|
|
105
|
+
? stateRoute.sourceAgent
|
|
106
|
+
: safeMetadata(latest.sourceAgent),
|
|
107
|
+
targetAgent: safeMetadata(latest.targetAgent) === "unknown"
|
|
108
|
+
? stateRoute.targetAgent
|
|
109
|
+
: safeMetadata(latest.targetAgent),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function getProjectStatus(projectPath, options = {}) {
|
|
113
|
+
const resolvedProjectPath = path.resolve(projectPath);
|
|
114
|
+
let stats;
|
|
115
|
+
try {
|
|
116
|
+
stats = await fs.stat(resolvedProjectPath);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new Error(`Cannot inspect project '${resolvedProjectPath}': ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (!stats.isDirectory()) {
|
|
122
|
+
throw new Error(`Project path is not a directory: ${resolvedProjectPath}`);
|
|
123
|
+
}
|
|
124
|
+
const [git, handoffCount, handoffs, codexSessions, claudeSessions] = await Promise.all([
|
|
125
|
+
inspectGit(resolvedProjectPath),
|
|
126
|
+
countTaskDirectories(resolvedProjectPath),
|
|
127
|
+
listHandoffs(resolvedProjectPath),
|
|
128
|
+
discoverCodexSessions(options.codexHome),
|
|
129
|
+
discoverClaudeSessions(options.claudeHomes),
|
|
130
|
+
]);
|
|
131
|
+
return {
|
|
132
|
+
projectPath: resolvedProjectPath,
|
|
133
|
+
isGitRepo: git.isGitRepo,
|
|
134
|
+
gitStatus: git.gitStatus,
|
|
135
|
+
handoffCount,
|
|
136
|
+
latestHandoff: await latestHandoffStatus(handoffs),
|
|
137
|
+
codexSessionCount: codexSessions.length,
|
|
138
|
+
claudeSessionCount: claudeSessions.length,
|
|
139
|
+
hammaIgnored: git.hammaIgnored,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function yesNo(value) {
|
|
143
|
+
return value ? "yes" : "no";
|
|
144
|
+
}
|
|
145
|
+
export function formatProjectStatus(status) {
|
|
146
|
+
const latest = status.latestHandoff;
|
|
147
|
+
const route = latest?.sourceAgent && latest.targetAgent
|
|
148
|
+
? `${latest.sourceAgent} → ${latest.targetAgent}`
|
|
149
|
+
: "unknown";
|
|
150
|
+
const ignored = status.hammaIgnored === null
|
|
151
|
+
? status.gitStatus === "unavailable"
|
|
152
|
+
? "n/a (git unavailable)"
|
|
153
|
+
: "n/a (not a git repository)"
|
|
154
|
+
: yesNo(status.hammaIgnored);
|
|
155
|
+
return [
|
|
156
|
+
`Project: ${status.projectPath}`,
|
|
157
|
+
`Git repository: ${yesNo(status.isGitRepo)}`,
|
|
158
|
+
`Git status: ${status.gitStatus}`,
|
|
159
|
+
`.hamma/tasks count: ${status.handoffCount}`,
|
|
160
|
+
`Latest handoff id: ${latest?.taskId ?? "none"}`,
|
|
161
|
+
`Latest handoff path: ${latest?.path ?? "none"}`,
|
|
162
|
+
`Latest source → target: ${latest ? route : "none"}`,
|
|
163
|
+
`Codex sessions: ${status.codexSessionCount}`,
|
|
164
|
+
`Claude sessions: ${status.claudeSessionCount}`,
|
|
165
|
+
`.hamma/ ignored: ${ignored}`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
3
|
+
/sk-proj-[A-Za-z0-9_-]{20,}/g,
|
|
4
|
+
/sk-ant-[A-Za-z0-9_-]{20,}/g,
|
|
5
|
+
/ghp_[A-Za-z0-9_]{20,}/g,
|
|
6
|
+
/github_pat_[A-Za-z0-9_]{20,}/g,
|
|
7
|
+
/AIza[0-9A-Za-z_-]{20,}/g,
|
|
8
|
+
/xox[baprs]-[A-Za-z0-9-]{20,}/g,
|
|
9
|
+
/(api[_-]?key|token|secret|password)\s*[:=]\s*['"]?[^'"\s]+/gi
|
|
10
|
+
];
|
|
11
|
+
export function redactText(input) {
|
|
12
|
+
let text = input;
|
|
13
|
+
let count = 0;
|
|
14
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
15
|
+
text = text.replace(pattern, () => {
|
|
16
|
+
count += 1;
|
|
17
|
+
return "[REDACTED_SECRET]";
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return { text, count };
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
export const HANDOFF_SCHEMA_VERSION = 1;
|
|
2
|
+
const IMPORTANT_USER_WORDS = /\b(audit|assess|fix|build|implement|proceed|resume|continue|task|verify|use mcp|minimize|do not)\b/i;
|
|
3
|
+
const COMPLETED_PATTERNS = [
|
|
4
|
+
/Task #?(\d+)\s+completed/gi,
|
|
5
|
+
/Task #?(\d+)\s+fixed/gi,
|
|
6
|
+
/Fixed finding #?(\d+)/gi,
|
|
7
|
+
];
|
|
8
|
+
const REMAINING_PATTERNS = [
|
|
9
|
+
/Next is task #?(\d+)(?::\s*([^\n.]+))?/gi,
|
|
10
|
+
/Remaining[^\n.]*task #?(\d+)(?::\s*([^\n.]+))?/gi,
|
|
11
|
+
/task #?(\d+)\s+remains/gi,
|
|
12
|
+
];
|
|
13
|
+
const LATEST_STATUS_MARKER = /\b(?:task #?\d+ (?:completed|fixed)|fixed finding #?\d+|completed|passes|remaining|next is task)\b/i;
|
|
14
|
+
const VERIFICATION_CATEGORIES = [
|
|
15
|
+
{
|
|
16
|
+
name: "Typecheck",
|
|
17
|
+
verb: "passes",
|
|
18
|
+
patterns: [/\btypecheck[^\n]*(?:passes|passed|clean|ok|no errors)/i, /\btsc[^\n]*(?:passes|passed|clean|no errors)/i],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "Production build",
|
|
22
|
+
verb: "passes",
|
|
23
|
+
patterns: [/production build[^\n]*(?:passes|passed|clean|succeed)/i, /\bbuild passes\b/i, /\bnpm run build\b[^\n]*(?:passes|passed|clean|succeed|success)/i],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "Targeted ESLint",
|
|
27
|
+
verb: "passes",
|
|
28
|
+
patterns: [
|
|
29
|
+
/targeted eslint/i,
|
|
30
|
+
/\beslint\b[^\n]*(?:passes|passed|0 errors|no errors|clean)/i,
|
|
31
|
+
/\blint\b[^\n]*(?:passes|passed|clean|0 errors)/i,
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "Tests",
|
|
36
|
+
verb: "pass",
|
|
37
|
+
patterns: [/tests?:\s*\d+\/\d+/i, /\b\d+\/\d+\s*(?:tests?\s*)?pass/i, /\ball tests pass\b/i],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "Browser/Playwright checks",
|
|
41
|
+
verb: "verified",
|
|
42
|
+
patterns: [/browser-tested/i, /\bplaywright\b[^\n]*(?:verified|passes|passed|checks?)/i, /playwright mcp/i, /browser mcp/i],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
const RISK_SIGNALS = [
|
|
46
|
+
/\bpre-existing\b/i,
|
|
47
|
+
/\bstill\s+(?:has|have|failing|failing?)\b/i,
|
|
48
|
+
/\bfailed\b/i,
|
|
49
|
+
/\bblocked\b/i,
|
|
50
|
+
/\bregression risk\b/i,
|
|
51
|
+
/unrelated worktree changes/i,
|
|
52
|
+
/\bknown (?:issue|risk|failure|bug)\b/i,
|
|
53
|
+
/\bcaveat\b/i,
|
|
54
|
+
];
|
|
55
|
+
const RISK_NEGATION_SIGNALS = [
|
|
56
|
+
/\bnow passes\b/i,
|
|
57
|
+
/\bpasses both\b/i,
|
|
58
|
+
/\bpassed both\b/i,
|
|
59
|
+
/\ball green\b/i,
|
|
60
|
+
/\bno more\b/i,
|
|
61
|
+
/\bno longer\b/i,
|
|
62
|
+
/\bnow (?:fixed|resolved|cleared|clean)\b/i,
|
|
63
|
+
/added [^\n]*?(?:test|regression|coverage)/i,
|
|
64
|
+
];
|
|
65
|
+
export function isImportantUserMessage(content) {
|
|
66
|
+
return IMPORTANT_USER_WORDS.test(content);
|
|
67
|
+
}
|
|
68
|
+
export function getMessageImportance(msg) {
|
|
69
|
+
if (msg.role === "system")
|
|
70
|
+
return "low";
|
|
71
|
+
if (msg.role === "user") {
|
|
72
|
+
return isImportantUserMessage(msg.content) ? "high" : "medium";
|
|
73
|
+
}
|
|
74
|
+
const c = msg.content;
|
|
75
|
+
if (COMPLETED_PATTERNS.some((p) => new RegExp(p.source, p.flags.replace("g", "")).test(c)) ||
|
|
76
|
+
REMAINING_PATTERNS.some((p) => new RegExp(p.source, p.flags.replace("g", "")).test(c)) ||
|
|
77
|
+
VERIFICATION_CATEGORIES.some((cat) => cat.patterns.some((p) => p.test(c))) ||
|
|
78
|
+
/\bnext is task\b/i.test(c) ||
|
|
79
|
+
/\bfixed finding\b/i.test(c)) {
|
|
80
|
+
return "high";
|
|
81
|
+
}
|
|
82
|
+
if (RISK_SIGNALS.some((p) => p.test(c)))
|
|
83
|
+
return "medium";
|
|
84
|
+
return "low";
|
|
85
|
+
}
|
|
86
|
+
function truncate(s, max) {
|
|
87
|
+
if (!s)
|
|
88
|
+
return "";
|
|
89
|
+
const t = s.trim();
|
|
90
|
+
if (t.length <= max)
|
|
91
|
+
return t;
|
|
92
|
+
return t.slice(0, max).trimEnd() + "…";
|
|
93
|
+
}
|
|
94
|
+
function firstParagraph(text, max) {
|
|
95
|
+
const cleaned = text.trim();
|
|
96
|
+
const block = cleaned.split(/\n\s*\n/)[0] ?? cleaned;
|
|
97
|
+
return truncate(block, max);
|
|
98
|
+
}
|
|
99
|
+
function mergeUnique(a, b) {
|
|
100
|
+
const s = new Set();
|
|
101
|
+
for (const x of a)
|
|
102
|
+
if (x)
|
|
103
|
+
s.add(x);
|
|
104
|
+
for (const x of b)
|
|
105
|
+
if (x)
|
|
106
|
+
s.add(x);
|
|
107
|
+
return Array.from(s);
|
|
108
|
+
}
|
|
109
|
+
function stripLineCol(p) {
|
|
110
|
+
return p.replace(/:\d+(?::\d+)?$/, "");
|
|
111
|
+
}
|
|
112
|
+
function stripTrailingPunct(p) {
|
|
113
|
+
return p.replace(/[)\],.:;!?'"`>]+$/, "");
|
|
114
|
+
}
|
|
115
|
+
export function extractFilePaths(text) {
|
|
116
|
+
const found = new Set();
|
|
117
|
+
const dirRe = /(?:^|[\s`"'([\]<>])((?:[/A-Za-z0-9_.-]+)?(?:src|app|components|lib|pages|public|scripts|tests?|hooks|utils|styles|content|config|api|server|packages)\/[A-Za-z0-9_\-./]+\.[A-Za-z0-9]+(?::\d+(?::\d+)?)?)/g;
|
|
118
|
+
for (const m of text.matchAll(dirRe)) {
|
|
119
|
+
let p = stripTrailingPunct(m[1]);
|
|
120
|
+
p = stripLineCol(p);
|
|
121
|
+
if (p.length >= 3 && p.length <= 300)
|
|
122
|
+
found.add(p);
|
|
123
|
+
}
|
|
124
|
+
const extRe = /(?:^|[\s`"'([\]<>/])([A-Za-z0-9_.-]+\.(?:tsx?|jsx?|css|json|md|ya?ml|html|toml))(?=[\s`"')\]:>,.]|$)/g;
|
|
125
|
+
for (const m of text.matchAll(extRe)) {
|
|
126
|
+
const p = stripTrailingPunct(m[1]);
|
|
127
|
+
if (p.length >= 3 && p.length <= 300 && !/^\.+$/.test(p))
|
|
128
|
+
found.add(p);
|
|
129
|
+
}
|
|
130
|
+
const absRe = /(\/(?:home|Users|root|tmp|var|opt|workspace|srv)\/[A-Za-z0-9_\-./]+(?::\d+(?::\d+)?)?)/g;
|
|
131
|
+
for (const m of text.matchAll(absRe)) {
|
|
132
|
+
let p = stripTrailingPunct(m[1]);
|
|
133
|
+
p = stripLineCol(p);
|
|
134
|
+
if (/\.[A-Za-z0-9]+$/.test(p) && p.length <= 300)
|
|
135
|
+
found.add(p);
|
|
136
|
+
}
|
|
137
|
+
return Array.from(found);
|
|
138
|
+
}
|
|
139
|
+
function cleanTitle(raw) {
|
|
140
|
+
let t = raw.replace(/\s+/g, " ").trim();
|
|
141
|
+
t = t.replace(/^(Critical|High|Medium|Low):\s*/i, "");
|
|
142
|
+
// Take up to the first sentence-ending punctuation followed by a space and a capital letter
|
|
143
|
+
const sentenceMatch = t.match(/^([\s\S]+?[.!?])\s+[A-Z]/);
|
|
144
|
+
if (sentenceMatch && sentenceMatch[1].length >= 10) {
|
|
145
|
+
t = sentenceMatch[1];
|
|
146
|
+
}
|
|
147
|
+
// Strip trailing "See [ref](path)..."
|
|
148
|
+
t = t.replace(/\s+See\s+\[[^\n]*$/, "");
|
|
149
|
+
// Strip trailing punctuation
|
|
150
|
+
t = t.replace(/[:.,;\s]+$/, "");
|
|
151
|
+
if (t.length === 0)
|
|
152
|
+
return t;
|
|
153
|
+
t = t.charAt(0).toUpperCase() + t.slice(1);
|
|
154
|
+
return t;
|
|
155
|
+
}
|
|
156
|
+
function extractPlanItems(text) {
|
|
157
|
+
const items = [];
|
|
158
|
+
const re = /(?:^|\n)\s{0,4}(\d+)\.\s+(.{5,400}?)(?=\n\s{0,4}\d+\.\s|\n\s*\n|$)/gs;
|
|
159
|
+
for (const m of text.matchAll(re)) {
|
|
160
|
+
const id = m[1];
|
|
161
|
+
const title = cleanTitle(m[2]);
|
|
162
|
+
if (title.length >= 5 && title.length <= 220) {
|
|
163
|
+
items.push({ id, title });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return items;
|
|
167
|
+
}
|
|
168
|
+
function buildTitleRegistry(assistants) {
|
|
169
|
+
const candidates = new Map();
|
|
170
|
+
let order = 0;
|
|
171
|
+
const add = (id, rawTitle, priority) => {
|
|
172
|
+
const title = cleanTitle(rawTitle);
|
|
173
|
+
if (title.length < 5 || title.length > 220)
|
|
174
|
+
return;
|
|
175
|
+
const arr = candidates.get(id) ?? [];
|
|
176
|
+
arr.push({ title, priority, order: order++ });
|
|
177
|
+
candidates.set(id, arr);
|
|
178
|
+
};
|
|
179
|
+
const introPatterns = [
|
|
180
|
+
{
|
|
181
|
+
re: /(?:I(?:'m|['’]m|['’]ve| am)?\s+(?:proceeding|starting|working|treating this as fixing|treating this as|kicking off))\s+(?:with\s+|on\s+|as\s+)?(?:finding|task)\s*#?(\d+):\s*([^\n]{6,300})/gi,
|
|
182
|
+
priority: 4,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
re: /Next is\s+(?:task|finding)\s*#?(\d+):\s*([^\n]{6,300})/gi,
|
|
186
|
+
priority: 3,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
re: /Fixed finding\s*#?(\d+):\s*([^\n]{6,300})/gi,
|
|
190
|
+
priority: 2,
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
for (const msg of assistants) {
|
|
194
|
+
for (const { re, priority } of introPatterns) {
|
|
195
|
+
for (const m of msg.content.matchAll(re)) {
|
|
196
|
+
add(m[1], m[2], priority);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const item of extractPlanItems(msg.content)) {
|
|
200
|
+
add(item.id, item.title, 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const registry = new Map();
|
|
204
|
+
for (const [id, list] of candidates) {
|
|
205
|
+
list.sort((a, b) => b.priority - a.priority || b.order - a.order);
|
|
206
|
+
registry.set(id, list[0].title);
|
|
207
|
+
}
|
|
208
|
+
return registry;
|
|
209
|
+
}
|
|
210
|
+
function splitClauses(text) {
|
|
211
|
+
const clauses = [];
|
|
212
|
+
for (const line of text.split(/\n+/)) {
|
|
213
|
+
const cleaned = line.replace(/^[\s>*\-–—•]+/, "").trim();
|
|
214
|
+
if (!cleaned)
|
|
215
|
+
continue;
|
|
216
|
+
const parts = cleaned.split(/(?<=[.!?])\s+(?=[A-Z"`\[])|;\s+/);
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
const t = part.trim();
|
|
219
|
+
if (t.length >= 6)
|
|
220
|
+
clauses.push(t);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return clauses;
|
|
224
|
+
}
|
|
225
|
+
function isActionableRisk(clause) {
|
|
226
|
+
const hasRisk = RISK_SIGNALS.some((p) => p.test(clause));
|
|
227
|
+
if (!hasRisk)
|
|
228
|
+
return false;
|
|
229
|
+
const negated = RISK_NEGATION_SIGNALS.some((p) => p.test(clause));
|
|
230
|
+
return !negated;
|
|
231
|
+
}
|
|
232
|
+
function tokenizeForSimilarity(s) {
|
|
233
|
+
return new Set(s
|
|
234
|
+
.toLowerCase()
|
|
235
|
+
.replace(/[^\w\s]/g, " ")
|
|
236
|
+
.split(/\s+/)
|
|
237
|
+
.filter((w) => w.length > 3));
|
|
238
|
+
}
|
|
239
|
+
function dedupRisks(risks, maxItems = 8) {
|
|
240
|
+
const kept = [];
|
|
241
|
+
for (const raw of risks) {
|
|
242
|
+
const r = raw.replace(/\s+/g, " ").trim();
|
|
243
|
+
if (!r)
|
|
244
|
+
continue;
|
|
245
|
+
const tr = tokenizeForSimilarity(r);
|
|
246
|
+
let mergedInto = -1;
|
|
247
|
+
for (let i = 0; i < kept.length; i++) {
|
|
248
|
+
const tk = tokenizeForSimilarity(kept[i]);
|
|
249
|
+
const inter = [...tr].filter((w) => tk.has(w));
|
|
250
|
+
const smaller = Math.min(tr.size, tk.size);
|
|
251
|
+
if (smaller > 0 && inter.length / smaller > 0.6) {
|
|
252
|
+
mergedInto = i;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (mergedInto === -1) {
|
|
257
|
+
kept.push(r);
|
|
258
|
+
}
|
|
259
|
+
else if (r.length < kept[mergedInto].length) {
|
|
260
|
+
kept[mergedInto] = r;
|
|
261
|
+
}
|
|
262
|
+
if (kept.length >= maxItems)
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
return kept;
|
|
266
|
+
}
|
|
267
|
+
function categorizeVerification(session) {
|
|
268
|
+
const buckets = VERIFICATION_CATEGORIES.map((c) => ({
|
|
269
|
+
name: c.name,
|
|
270
|
+
verb: c.verb,
|
|
271
|
+
count: 0,
|
|
272
|
+
}));
|
|
273
|
+
for (const msg of session.messages) {
|
|
274
|
+
if (msg.role !== "assistant")
|
|
275
|
+
continue;
|
|
276
|
+
for (const rawLine of msg.content.split(/\n/)) {
|
|
277
|
+
const line = rawLine.replace(/^[\s>*\-–—•]+/, "").trim();
|
|
278
|
+
if (!line)
|
|
279
|
+
continue;
|
|
280
|
+
for (let i = 0; i < VERIFICATION_CATEGORIES.length; i++) {
|
|
281
|
+
const cat = VERIFICATION_CATEGORIES[i];
|
|
282
|
+
if (cat.patterns.some((p) => p.test(line))) {
|
|
283
|
+
buckets[i].count += 1;
|
|
284
|
+
if (cat.name === "Tests") {
|
|
285
|
+
const nm = line.match(/(\d+)\/(\d+)/);
|
|
286
|
+
if (nm)
|
|
287
|
+
buckets[i].lastNumericSample = `${nm[1]}/${nm[2]}`;
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const results = [];
|
|
295
|
+
for (const b of buckets) {
|
|
296
|
+
if (b.count === 0)
|
|
297
|
+
continue;
|
|
298
|
+
if (b.name === "Tests" && b.lastNumericSample) {
|
|
299
|
+
results.push(`Tests: ${b.lastNumericSample} pass (${b.count} confirmation${b.count === 1 ? "" : "s"})`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
results.push(`${b.name}: ${b.verb} (${b.count} confirmation${b.count === 1 ? "" : "s"})`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return results;
|
|
306
|
+
}
|
|
307
|
+
function collectFileMentions(messages) {
|
|
308
|
+
const files = new Set();
|
|
309
|
+
for (const msg of messages) {
|
|
310
|
+
for (const p of extractFilePaths(msg.content))
|
|
311
|
+
files.add(p);
|
|
312
|
+
}
|
|
313
|
+
return Array.from(files);
|
|
314
|
+
}
|
|
315
|
+
function collectRisks(messages) {
|
|
316
|
+
const raw = [];
|
|
317
|
+
for (const msg of messages) {
|
|
318
|
+
if (msg.role === "system")
|
|
319
|
+
continue;
|
|
320
|
+
for (const clause of splitClauses(msg.content)) {
|
|
321
|
+
if (isActionableRisk(clause)) {
|
|
322
|
+
raw.push(truncate(clause, 240));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return dedupRisks(raw);
|
|
327
|
+
}
|
|
328
|
+
export function extractTaskState(session, options) {
|
|
329
|
+
const { targetCli, repoState } = options;
|
|
330
|
+
const messages = session.messages.filter((m) => m.role !== "system");
|
|
331
|
+
const users = messages.filter((m) => m.role === "user");
|
|
332
|
+
const assistants = messages.filter((m) => m.role === "assistant");
|
|
333
|
+
const importantUsers = users.filter((u) => isImportantUserMessage(u.content));
|
|
334
|
+
const firstUser = users[0];
|
|
335
|
+
const goalUser = firstUser && firstUser.content.trim().length >= 20 ? firstUser : importantUsers[0] ?? firstUser;
|
|
336
|
+
const latestImportantUser = importantUsers[importantUsers.length - 1] ?? users[users.length - 1];
|
|
337
|
+
const latestStatusAssistant = [...assistants].reverse().find((a) => LATEST_STATUS_MARKER.test(a.content)) ??
|
|
338
|
+
assistants[assistants.length - 1];
|
|
339
|
+
const titleRegistry = buildTitleRegistry(assistants);
|
|
340
|
+
const tasksById = new Map();
|
|
341
|
+
for (const msg of assistants) {
|
|
342
|
+
for (const pat of COMPLETED_PATTERNS) {
|
|
343
|
+
for (const mm of msg.content.matchAll(pat)) {
|
|
344
|
+
const id = mm[1];
|
|
345
|
+
const files = extractFilePaths(msg.content);
|
|
346
|
+
const existing = tasksById.get(id);
|
|
347
|
+
const summary = firstParagraph(msg.content, 400);
|
|
348
|
+
tasksById.set(id, {
|
|
349
|
+
id,
|
|
350
|
+
title: titleRegistry.get(id) ?? existing?.title,
|
|
351
|
+
status: "completed",
|
|
352
|
+
summary: existing?.status === "completed" && existing.summary ? existing.summary : summary,
|
|
353
|
+
evidence: mergeUnique(existing?.evidence ?? [], msg.timestamp ? [msg.timestamp] : []),
|
|
354
|
+
risks: existing?.risks ?? [],
|
|
355
|
+
filesMentioned: mergeUnique(existing?.filesMentioned ?? [], files),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const pat of REMAINING_PATTERNS) {
|
|
360
|
+
for (const mm of msg.content.matchAll(pat)) {
|
|
361
|
+
const id = mm[1];
|
|
362
|
+
const existing = tasksById.get(id);
|
|
363
|
+
if (existing && existing.status === "completed")
|
|
364
|
+
continue;
|
|
365
|
+
const files = extractFilePaths(msg.content);
|
|
366
|
+
tasksById.set(id, {
|
|
367
|
+
id,
|
|
368
|
+
title: titleRegistry.get(id) ?? existing?.title,
|
|
369
|
+
status: "remaining",
|
|
370
|
+
summary: existing?.summary || firstParagraph(msg.content, 300),
|
|
371
|
+
evidence: mergeUnique(existing?.evidence ?? [], msg.timestamp ? [msg.timestamp] : []),
|
|
372
|
+
risks: existing?.risks ?? [],
|
|
373
|
+
filesMentioned: mergeUnique(existing?.filesMentioned ?? [], files),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const [id, title] of titleRegistry) {
|
|
379
|
+
if (tasksById.has(id))
|
|
380
|
+
continue;
|
|
381
|
+
tasksById.set(id, {
|
|
382
|
+
id,
|
|
383
|
+
title,
|
|
384
|
+
status: "remaining",
|
|
385
|
+
summary: truncate(title, 300),
|
|
386
|
+
evidence: [],
|
|
387
|
+
risks: [],
|
|
388
|
+
filesMentioned: [],
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const tasks = Array.from(tasksById.values()).sort((a, b) => {
|
|
392
|
+
const na = Number(a.id ?? 0);
|
|
393
|
+
const nb = Number(b.id ?? 0);
|
|
394
|
+
if (Number.isFinite(na) && Number.isFinite(nb))
|
|
395
|
+
return na - nb;
|
|
396
|
+
return 0;
|
|
397
|
+
});
|
|
398
|
+
const verification = categorizeVerification(session);
|
|
399
|
+
const risksAgg = collectRisks(messages);
|
|
400
|
+
const filesAgg = collectFileMentions(messages);
|
|
401
|
+
const remainingTasks = tasks.filter((t) => t.status === "remaining");
|
|
402
|
+
let nextRecommended;
|
|
403
|
+
if (remainingTasks.length > 0) {
|
|
404
|
+
const t = remainingTasks[0];
|
|
405
|
+
const label = t.title ?? t.summary;
|
|
406
|
+
nextRecommended = `Task #${t.id}: ${truncate(label, 220)}`;
|
|
407
|
+
}
|
|
408
|
+
else if (latestImportantUser) {
|
|
409
|
+
nextRecommended = truncate(latestImportantUser.content, 240);
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
schemaVersion: HANDOFF_SCHEMA_VERSION,
|
|
413
|
+
goal: goalUser ? truncate(goalUser.content, 500) : undefined,
|
|
414
|
+
project: {
|
|
415
|
+
path: session.meta.projectPath,
|
|
416
|
+
sourceCli: session.meta.sourceCli,
|
|
417
|
+
targetCli,
|
|
418
|
+
sourceSessionId: session.meta.sourceSessionId,
|
|
419
|
+
sourcePath: session.meta.sourcePath,
|
|
420
|
+
startedAt: session.meta.startedAt,
|
|
421
|
+
lastUpdatedAt: session.meta.lastUpdatedAt,
|
|
422
|
+
},
|
|
423
|
+
current: {
|
|
424
|
+
latestUserInstruction: latestImportantUser
|
|
425
|
+
? truncate(latestImportantUser.content, 500)
|
|
426
|
+
: undefined,
|
|
427
|
+
latestAssistantStatus: latestStatusAssistant
|
|
428
|
+
? truncate(latestStatusAssistant.content, 900)
|
|
429
|
+
: undefined,
|
|
430
|
+
nextRecommendedTask: nextRecommended,
|
|
431
|
+
},
|
|
432
|
+
tasks,
|
|
433
|
+
verification,
|
|
434
|
+
risks: risksAgg,
|
|
435
|
+
filesMentioned: filesAgg,
|
|
436
|
+
repoState,
|
|
437
|
+
references: {
|
|
438
|
+
fullSession: "session.json",
|
|
439
|
+
timeline: "timeline.md",
|
|
440
|
+
commands: "commands.md",
|
|
441
|
+
redactionReport: "redaction-report.md",
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CodexAdapter } from "./adapters/codex/index.js";
|
|
4
|
+
import { ClaudeAdapter } from "./adapters/claude/index.js";
|
|
5
|
+
import { sessionIdFromFilename } from "./adapters/claude/paths.js";
|
|
6
|
+
async function resolveDirectClaudePath(target) {
|
|
7
|
+
const absolutePath = path.resolve(target);
|
|
8
|
+
if (!path.isAbsolute(target) || path.extname(absolutePath) !== ".jsonl") {
|
|
9
|
+
throw new Error(`Unsupported session target '${target}'. Use codex:<id>, claude:<id>, a Codex rollout path, or an absolute UUID-named Claude session path.`);
|
|
10
|
+
}
|
|
11
|
+
const sessionId = sessionIdFromFilename(absolutePath);
|
|
12
|
+
const fileStem = path.basename(absolutePath, path.extname(absolutePath));
|
|
13
|
+
if (!sessionId || fileStem !== sessionId) {
|
|
14
|
+
throw new Error(`Cannot safely identify direct JSONL path as a Claude session: ${absolutePath}`);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const stat = await fs.stat(absolutePath);
|
|
18
|
+
if (!stat.isFile()) {
|
|
19
|
+
throw new Error(`Claude session path is not a regular file: ${absolutePath}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err.code === "ENOENT") {
|
|
24
|
+
throw new Error(`Claude session file does not exist: ${absolutePath}`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
return absolutePath;
|
|
29
|
+
}
|
|
30
|
+
export async function resolveSessionTarget(target, options = {}) {
|
|
31
|
+
if (target.startsWith("claude:")) {
|
|
32
|
+
return {
|
|
33
|
+
sourceCli: "claude",
|
|
34
|
+
sessionPath: await ClaudeAdapter.resolve(target, options.claudeHomes)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (target.startsWith("codex:") || path.basename(target).startsWith("rollout-")) {
|
|
38
|
+
return {
|
|
39
|
+
sourceCli: "codex",
|
|
40
|
+
sessionPath: await CodexAdapter.resolve(target, options.codexHome)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
sourceCli: "claude",
|
|
45
|
+
sessionPath: await resolveDirectClaudePath(target)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function loadSession(target, options = {}) {
|
|
49
|
+
const resolved = await resolveSessionTarget(target, options);
|
|
50
|
+
if (resolved.sourceCli === "claude") {
|
|
51
|
+
return ClaudeAdapter.inspect(resolved.sessionPath);
|
|
52
|
+
}
|
|
53
|
+
return CodexAdapter.inspect(resolved.sessionPath);
|
|
54
|
+
}
|