From 6b05c98caf0e7121cee71998e4dd979bbb5af516 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:33:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(history):=20=E7=94=A8=20transcripts.mj?= =?UTF-8?q?s=20=E6=8C=89=20cwd=20=E4=B8=8E=E6=97=B6=E9=97=B4=E5=88=97?= =?UTF-8?q?=E5=87=BA=E5=8E=86=E5=8F=B2=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recall、correct、automate-me 改为调用 bin/transcripts.mjs 定位本项目的 Claude 与 Codex 会话,不再只靠文字描述路径。 - Claude:按 Claude Code 2.1.302 的规则把 cwd 转成项目目录名(非字母数字 的 UTF-16 码元换成 -,超过 200 字符截断并加哈希),并要求会话记录过该 cwd;同时列出 subagents/。 - Codex:先按 mtime 过滤 sessions/YYYY/MM/DD,只读窗口内文件首行 session_meta 的 payload.cwd。 - scripts/transcripts.test.mjs 用临时 HOME 覆盖 slug、目录冲突与搬迁会话、 时间窗、cwd 过滤和两种宿主。 Co-Authored-By: Claude Opus 5.5 --- ADAPTATION.md | 2 + bin/transcripts.mjs | 251 +++++++++++++++++++++++++++++++++++ references/claude-runtime.md | 2 +- references/codex-runtime.md | 2 +- references/runtime.md | 4 +- scripts/transcripts.test.mjs | 190 ++++++++++++++++++++++++++ skills/automate-me/SKILL.md | 4 +- skills/correct/SKILL.md | 2 +- skills/recall/SKILL.md | 6 +- 9 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 bin/transcripts.mjs create mode 100644 scripts/transcripts.test.mjs diff --git a/ADAPTATION.md b/ADAPTATION.md index 70a21a7..3d3eeaf 100644 --- a/ADAPTATION.md +++ b/ADAPTATION.md @@ -10,6 +10,8 @@ Shared preferences live at `~/.config/pstack/config.json`. `bin/config.mjs` supp `references/runtime.md` holds the shared contract. The Codex and Claude adapters hold only native tool and goal differences. Every skill reads the shared contract; parent agents relay the same resolved policy to workers. Canonical project skills remain in `.agents/skills`, with individual non-overwriting `.claude/skills` links for discovery. Existing projects are not rewritten by installation. +`bin/transcripts.mjs` is a local addition with no upstream counterpart. `recall`, `correct` and `automate-me` list prior sessions through it instead of describing transcript paths in prose, and it enforces the shared runtime's cwd and time restriction before any content is read. Claude sessions come only from the project directory Claude Code derives from each requested cwd, and a session counts only if it recorded that cwd, because distinct paths can share a directory name and a session moved from another directory keeps its earlier cwd. Codex rollouts are opened only when modified inside the window, and only for their first `session_meta` record. The Claude directory rule was read from Claude Code 2.1.302 and checked against every existing project directory on the maintainer's machine; upstream's harness note (cursor/plugins#637) names only `/` and `.`, which misses paths with spaces, underscores or non-ASCII characters and those longer than 200 characters. `scripts/transcripts.test.mjs` covers both hosts with a temporary home. + Benny remains dormant. Its copied runtime entry discovers this shared contract instead of maintaining a duplicated adapter. Installing the package does not arm messages, remote writes, webhooks, goals, or scheduled runs. ## Upstream sync diff --git a/bin/transcripts.mjs b/bin/transcripts.mjs new file mode 100644 index 0000000..ff6a773 --- /dev/null +++ b/bin/transcripts.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +// Lists one checkout's own session transcripts, newest first, for recall, correct and automate-me. +// A session matches by the working directory its host recorded; the helper reads that metadata and +// never prints message content. +import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOSTS = ["claude", "codex"]; +const DEFAULT_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; +// Claude Code (checked against 2.1.302 and this machine's project directories) replaces every UTF-16 +// code unit outside [a-zA-Z0-9] with "-", so spaces, dots, underscores and CJK become one dash and an +// emoji two; past 200 characters it keeps the first 200 and appends a base-36 string hash. +const CLAUDE_SLUG_LIMIT = 200; +const READ_CHUNK = 64 * 1024; +const NEWLINE = 0x0a; + +function claudeProjectSlug(cwd) { + const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-"); + if (slug.length <= CLAUDE_SLUG_LIMIT) return slug; + let hash = 0; + for (let i = 0; i < cwd.length; i += 1) hash = ((hash << 5) - hash + cwd.charCodeAt(i)) | 0; + return `${slug.slice(0, CLAUDE_SLUG_LIMIT)}-${Math.abs(hash).toString(36)}`; +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function statOrNull(file) { + return statSync(file, { throwIfNoEntry: false }) ?? null; +} + +function entries(dir) { + return statOrNull(dir)?.isDirectory() ? readdirSync(dir, { withFileTypes: true }) : []; +} + +// Splits only at newline bytes, which never occur inside a UTF-8 sequence, so each line decodes whole. +// Stopping early closes the file through the generator's finally block. +function* lines(file) { + const fd = openSync(file, "r"); + try { + const buffer = Buffer.alloc(READ_CHUNK); + let pending = []; + for (let bytes; (bytes = readSync(fd, buffer, 0, buffer.length, null)) > 0;) { + const chunk = buffer.subarray(0, bytes); + let start = 0; + for (let end; (end = chunk.indexOf(NEWLINE, start)) !== -1; start = end + 1) { + pending.push(chunk.subarray(start, end)); + yield Buffer.concat(pending).toString("utf8"); + pending = []; + } + pending.push(Buffer.from(chunk.subarray(start))); + } + const rest = Buffer.concat(pending); + if (rest.length) yield rest.toString("utf8"); + } finally { + closeSync(fd); + } +} + +function parseRecord(line) { + try { + const value = JSON.parse(line); + return isObject(value) ? value : null; + } catch { + return null; + } +} + +function transcript(file, stat, fields) { + return { ...fields, path: file, mtime: new Date(stat.mtimeMs).toISOString(), size: stat.size }; +} + +// A session can start elsewhere and be moved into this directory, and distinct paths such as a-b and +// a.b share one directory name, so the first recorded cwd is not enough: any top-level cwd must match. +function claudeSessionCwd(file, cwds) { + for (const line of lines(file)) { + const cwd = parseRecord(line)?.cwd; + if (cwds.has(cwd)) return cwd; + } + return null; +} + +function listClaude({ cwds, inWindow, env, homeDir }) { + const root = path.join(env.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude"), "projects"); + const found = []; + for (const dir of new Set([...cwds].map((cwd) => path.join(root, claudeProjectSlug(cwd))))) { + for (const entry of entries(dir)) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const session = entry.name.slice(0, -".jsonl".length); + const file = path.join(dir, entry.name); + const subagentDir = path.join(dir, session, "subagents"); + const candidates = [ + { file, fields: { kind: "session", session, parent: null } }, + ...entries(subagentDir) + .filter((sub) => sub.isFile() && sub.name.endsWith(".jsonl")) + .map((sub) => ({ file: path.join(subagentDir, sub.name), fields: { kind: "subagent", session: sub.name.slice(0, -".jsonl".length), parent: session } })), + ] + .map((candidate) => ({ ...candidate, stat: statSync(candidate.file) })) + .filter((candidate) => inWindow(candidate.stat.mtimeMs)); + if (!candidates.length) continue; + const cwd = claudeSessionCwd(file, cwds); + if (cwd === null) continue; + for (const candidate of candidates) found.push(transcript(candidate.file, candidate.stat, { ...candidate.fields, cwd })); + } + } + return found; +} + +function* codexRollouts(root) { + const numbered = (dir, digits) => entries(dir).filter((entry) => entry.isDirectory() && new RegExp(`^\\d{${digits}}$`).test(entry.name)); + for (const year of numbered(root, 4)) { + for (const month of numbered(path.join(root, year.name), 2)) { + for (const day of numbered(path.join(root, year.name, month.name), 2)) { + const dir = path.join(root, year.name, month.name, day.name); + for (const entry of entries(dir)) { + if (entry.isFile() && entry.name.endsWith(".jsonl")) yield path.join(dir, entry.name); + } + } + } + } +} + +function firstRecord(file) { + for (const line of lines(file)) return parseRecord(line); + return null; +} + +// Codex keeps no per-project directory, so a rollout's cwd is in its first record (session_meta). +// The mtime check comes first: only rollouts inside the window are opened, and only that first line. +function listCodex({ cwds, inWindow, env, homeDir }) { + const root = path.join(env.CODEX_HOME || path.join(homeDir, ".codex"), "sessions"); + const found = []; + for (const file of codexRollouts(root)) { + const stat = statSync(file); + if (!inWindow(stat.mtimeMs)) continue; + const record = firstRecord(file); + const meta = record?.type === "session_meta" && isObject(record.payload) ? record.payload : null; + if (!meta || !cwds.has(meta.cwd)) continue; + const subagent = isObject(meta.source) && isObject(meta.source.subagent) ? meta.source.subagent : null; + const parent = subagent?.thread_spawn?.parent_thread_id; + found.push(transcript(file, stat, { + kind: subagent ? "subagent" : "session", + session: typeof meta.id === "string" ? meta.id : null, + parent: typeof parent === "string" ? parent : null, + cwd: meta.cwd, + })); + } + return found; +} + +// Hosts record the process cwd, which resolves symlinks (macOS /tmp is /private/tmp), so both +// spellings match. A directory that no longer exists, such as a removed worktree, still matches as given. +function matchableCwds(cwds) { + const forms = new Set(); + for (const cwd of cwds) { + const resolved = path.resolve(cwd); + forms.add(resolved); + try { + forms.add(realpathSync.native(resolved)); + } catch (error) { + if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; + } + } + return forms; +} + +export function listTranscripts({ host, cwds, since, until, env = process.env, homeDir = homedir() }) { + if (!HOSTS.includes(host)) throw new Error(`host must be one of ${HOSTS.join(" or ")}`); + if (!cwds.length) throw new Error("at least one cwd is required"); + if (since > until) throw new Error("since must not be later than until"); + const options = { cwds: matchableCwds(cwds), inWindow: (mtime) => mtime >= since && mtime <= until, env, homeDir }; + const found = host === "claude" ? listClaude(options) : listCodex(options); + found.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.path.localeCompare(b.path)); + return { + host, + cwds: cwds.map((cwd) => path.resolve(cwd)), + since: new Date(since).toISOString(), + until: new Date(until).toISOString(), + transcripts: found, + }; +} + +// A bare date is a local calendar day: --since starts at its midnight and --until includes the whole day. +function parseTime(value, flag, { endOfDay }) { + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (day) { + const [year, month, date] = day.slice(1).map(Number); + const start = new Date(year, month - 1, date); + if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== date) { + throw new Error(`${flag} ${value} is not a calendar date`); + } + return endOfDay ? new Date(year, month - 1, date + 1).getTime() - 1 : start.getTime(); + } + const time = /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : Number.NaN; + if (Number.isNaN(time)) throw new Error(`${flag} must be YYYY-MM-DD or an ISO 8601 date-time`); + return time; +} + +const USAGE = "Usage: transcripts.mjs --host claude|codex [--cwd dir]... [--since YYYY-MM-DD|date-time] [--until YYYY-MM-DD|date-time] [--json]"; + +function parseArgs(args) { + const options = { cwd: [], json: false }; + for (let i = 0; i < args.length; i += 1) { + const flag = args[i]; + if (flag === "--json") { + options.json = true; + continue; + } + const key = flag.startsWith("--") ? flag.slice(2) : null; + if (!["host", "cwd", "since", "until"].includes(key)) throw new Error(`Unknown argument: ${flag}\n${USAGE}`); + const value = args[i + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`Option ${flag} requires a value`); + if (key === "cwd") options.cwd.push(value); + else if (Object.hasOwn(options, key)) throw new Error(`Option ${flag} may only be provided once`); + else options[key] = value; + i += 1; + } + if (!options.host) throw new Error(`--host is required\n${USAGE}`); + return options; +} + +function runCli(args) { + const options = parseArgs(args); + const now = Date.now(); + const result = listTranscripts({ + host: options.host, + cwds: options.cwd.length ? options.cwd : [process.cwd()], + since: options.since === undefined ? now - DEFAULT_DAYS * DAY_MS : parseTime(options.since, "--since", { endOfDay: false }), + until: options.until === undefined ? now : parseTime(options.until, "--until", { endOfDay: true }), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + for (const item of result.transcripts) process.stdout.write(`${item.mtime}\t${item.kind}\t${item.size}\t${item.path}\n`); + process.stderr.write(`${result.transcripts.length} ${result.host} transcript(s) for ${result.cwds.join(", ")} from ${result.since} to ${result.until}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/references/claude-runtime.md b/references/claude-runtime.md index f311958..e474121 100644 --- a/references/claude-runtime.md +++ b/references/claude-runtime.md @@ -10,6 +10,6 @@ Read [the shared runtime](./runtime.md) first. This file defines only Claude Cod - For isolated writers, use an actual worktree at the intended base SHA. Do not assume the default branch used by a host-created worktree matches the parent's HEAD. Verify it. - Claude's native `/goal` is a session feature with its own evaluator and lifecycle. Check for an existing goal before replacing it. A skill invocation does not activate one. On explicit user intent, use the native command/control actually exposed in that session; if it requires user command entry, explain that exact prerequisite and continue feasible work. Do not invent `create_goal`, `get_goal`, or `update_goal` tools. Surface the completion evidence for the evaluator instead of pretending to set Codex-style statuses. On stop/pause follow the host's real goal clearing behavior and preserve a checkpoint. - `/loop` and Cron tools, when present, handle separately requested session scheduling. Verify the actual job ID and cadence. A goal is not a schedule; a background Bash process is not a durable automation. Do not promise operation after session exit or restart without testing that exact path. -- Project/user instructions come from the current Claude environment. Use supported history/resume tools or narrowly scoped local session records; do not invoke Codex task APIs or assume Cursor transcript paths. +- Project/user instructions come from the current Claude environment. Use supported history/resume tools first. For local session records, `bin/transcripts.mjs --host claude` reads `~/.claude/projects//` (under `CLAUDE_CONFIG_DIR` when set), where the slug is the cwd with every UTF-16 code unit outside `[A-Za-z0-9]` replaced by `-` and, past 200 characters, cut to 200 plus a hash. It keeps only sessions that recorded the requested cwd and lists their `subagents/` files with them. Do not invoke Codex task APIs or assume Cursor transcript paths. Official references: [plugins](https://code.claude.com/docs/en/plugins), [subagents](https://code.claude.com/docs/en/sub-agents), [goal](https://code.claude.com/docs/en/goal), [scheduling](https://code.claude.com/docs/en/scheduled-tasks). diff --git a/references/codex-runtime.md b/references/codex-runtime.md index d0cba1c..4d40d51 100644 --- a/references/codex-runtime.md +++ b/references/codex-runtime.md @@ -9,4 +9,4 @@ Read [the shared runtime](./runtime.md) first. This file defines only Codex-spec - Close children only after a final answer, explicit completion event, or confirmed completed/failed/canceled status. Preserve host-specific cancellation and progress rules. - Create a native goal only on an explicit user request. Use actual `create_goal`, `get_goal`, and `update_goal` schemas. Reuse a matching active goal, never replace another one implicitly. Mark complete only with evidence; pause only on request; use blocked only under the tool's repeated-blocker conditions. Set a token budget only when the user requested one. Retain returned final usage accounting when applicable. - Native goals do not imply timers. Use available process/session waits, events, or separately authorized automations. Report missing continuation or scheduling honestly. -- Prefer supported Codex task/history tools for prior sessions. Apply the user's current AGENTS.md and applicable local rules; do not copy user-specific rules into the plugin. +- Prefer supported Codex task/history tools for prior sessions. For local rollouts, `bin/transcripts.mjs --host codex` lists `~/.codex/sessions/YYYY/MM/DD/*.jsonl` (under `CODEX_HOME` when set) whose first record, `session_meta`, has `payload.cwd` equal to the checkout, and marks `payload.source.subagent` rollouts as subagents. It opens only rollouts modified inside the window, and only to read that first record. Compressed rollouts in `archived_sessions/` are not listed. Apply the user's current AGENTS.md and applicable local rules; do not copy user-specific rules into the plugin. diff --git a/references/runtime.md b/references/runtime.md index c4f6faa..8de8035 100644 --- a/references/runtime.md +++ b/references/runtime.md @@ -65,6 +65,6 @@ Prefer the project's existing verification harness. In this user's environment, Upstream `deslop`, `control-ui`, and `control-cli` dependencies are replaced by direct diff review and actually available tools. `gh` is the default forge; use Origin/Graphite only when present, compatible, and authorized. Do not install optional connectors or activate the dormant Benny pack implicitly. -Use the current task and authorized project artifacts first. For history, follow the host adapter; restrict local transcript candidates by repository/cwd, session, and time before reading content. Do not mine unrelated sessions or write persistent memory without an explicit request. +Use the current task and authorized project artifacts first. For history, follow the host adapter; restrict local transcript candidates by repository/cwd, session, and time before reading content. `node /bin/transcripts.mjs --host --cwd [--since ] [--until ] [--json]` applies the cwd and time restriction (default: the current directory and the last 7 days) and lists the candidates newest first by modification time; read only listed files, and only the parts the task needs. A linked worktree records its own cwd, so pass every checkout in scope (`git worktree list`) as another `--cwd`. Do not mine unrelated sessions or write persistent memory without an explicit request. -The Node helpers `bin/orch.mjs`, `bin/watch-pr.mjs`, and `bin/config.mjs` need no runtime dependency downloads. Resolve them from the plugin root, never the target project's current directory. Source helper development uses Bun; only rebuild the existing bundles when their TypeScript sources change. Keep verification evidence after cleanup. Discovery, structural validation, script tests, and actual agent behavior are separate gates. +The Node helpers `bin/orch.mjs`, `bin/watch-pr.mjs`, `bin/config.mjs`, and `bin/transcripts.mjs` need no runtime dependency downloads. Resolve them from the plugin root, never the target project's current directory. Source helper development uses Bun; only rebuild the existing bundles when their TypeScript sources change. Keep verification evidence after cleanup. Discovery, structural validation, script tests, and actual agent behavior are separate gates. diff --git a/scripts/transcripts.test.mjs b/scripts/transcripts.test.mjs new file mode 100644 index 0000000..d2b7bc2 --- /dev/null +++ b/scripts/transcripts.test.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const root = path.resolve(import.meta.dirname, ".."); +const cli = path.join(root, "bin/transcripts.mjs"); +const DAY = 24 * 60 * 60 * 1000; +const now = Date.now(); +// Message content the helper must never print. +const SECRET = "SECRET-CONTENT-MARKER"; + +function tempHome(t) { + const home = mkdtempSync(path.join(os.tmpdir(), "pstack-transcripts-test-")); + t.after(() => rmSync(home, { recursive: true, force: true })); + return home; +} + +function writeJsonl(file, records, ageDays) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, records.map((record) => `${JSON.stringify(record)}\n`).join("")); + const mtime = new Date(now - ageDays * DAY); + utimesSync(file, mtime, mtime); + return file; +} + +// A Claude session as Claude Code writes it: a cwd-less header, then messages that record the cwd. +function claudeSession(home, dirName, session, cwd, ageDays) { + return writeJsonl(path.join(home, ".claude/projects", dirName, `${session}.jsonl`), [ + { type: "permission-mode", sessionId: session }, + { type: "user", cwd, sessionId: session, message: { role: "user", content: SECRET } }, + ], ageDays); +} + +function codexRollout(home, day, id, cwd, ageDays, source = "cli") { + return writeJsonl(path.join(home, ".codex/sessions", ...day.split("-"), `rollout-${day}T00-00-00-${id}.jsonl`), [ + { timestamp: new Date(now).toISOString(), type: "session_meta", payload: { id, session_id: id, cwd, source, base_instructions: { text: SECRET } } }, + { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: SECRET }] } }, + ], ageDays); +} + +function run(home, args, env = {}) { + const clean = { ...process.env, HOME: home }; + delete clean.CLAUDE_CONFIG_DIR; + delete clean.CODEX_HOME; + return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", env: { ...clean, ...env } }); +} + +function list(home, args, env) { + const result = run(home, [...args, "--json"], env); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function paths(listing) { + return listing.transcripts.map((item) => item.path); +} + +test("Given cwds with spaces, dots, underscores, non-ASCII and over 200 characters, When Claude sessions are listed, Then each is found under Claude Code's directory name", (t) => { + const home = tempHome(t); + const cases = [ + ["/work/Application Support/my_app.v2", "-work-Application-Support-my-app-v2"], + ["/work/项目/🚀", "-work------"], + [ + `/work/${Array(15).fill("nested-segment").join("/")}`, + `${"-work-nested-segment".padEnd(200, "-nested-segment")}-hw57gs`, + ], + ]; + for (const [index, [cwd, dirName]] of cases.entries()) { + const file = claudeSession(home, dirName, `session-${index}`, cwd, 1); + // Upstream's '/' and '.' rule would look here; nothing may be found there. + claudeSession(home, cwd.replace(/[/.]/g, "-"), `decoy-${index}`, cwd, 1); + assert.deepEqual(paths(list(home, ["--host", "claude", "--cwd", cwd])), [file], cwd); + } +}); + +test("Given one Claude directory shared by colliding paths and a moved session, When one cwd is listed, Then only sessions that recorded that cwd and their subagents are returned", (t) => { + const home = tempHome(t); + const dir = path.join(home, ".claude/projects/-work-a-b"); + const own = claudeSession(home, "-work-a-b", "own", "/work/a.b", 2); + claudeSession(home, "-work-a-b", "collision", "/work/a-b", 1); + const moved = writeJsonl(path.join(dir, "moved.jsonl"), [ + { type: "user", cwd: "/scratch/start", message: { content: "first" } }, + { type: "user", cwd: "/work/a.b", message: { content: "after the move" } }, + ], 3); + const subagent = writeJsonl(path.join(dir, "own/subagents/agent-a1.jsonl"), [{ type: "user", cwd: "/work/a.b/.claude/worktrees/w" }], 2.5); + writeFileSync(path.join(dir, "own/subagents/agent-a1.meta.json"), "{}"); + writeJsonl(path.join(dir, "own/tool-results/out.jsonl"), [{ cwd: "/work/a.b" }], 1); + writeJsonl(path.join(dir, "memory/notes.jsonl"), [{ cwd: "/work/a.b" }], 1); + + const listing = list(home, ["--host", "claude", "--cwd", "/work/a.b"]); + assert.deepEqual(paths(listing), [own, subagent, moved]); + assert.deepEqual(listing.transcripts.map(({ kind, session, parent, cwd }) => ({ kind, session, parent, cwd })), [ + { kind: "session", session: "own", parent: null, cwd: "/work/a.b" }, + { kind: "subagent", session: "agent-a1", parent: "own", cwd: "/work/a.b" }, + { kind: "session", session: "moved", parent: null, cwd: "/work/a.b" }, + ]); + assert.ok(!JSON.stringify(listing).includes(SECRET)); +}); + +test("Given Claude sessions of several ages, When listed by default and with explicit bounds, Then only files whose own mtime is in the window appear, newest first", (t) => { + const home = tempHome(t); + const cwd = "/work/repo"; + const recent = claudeSession(home, "-work-repo", "recent", cwd, 1); + const older = claudeSession(home, "-work-repo", "older", cwd, 6); + const stale = claudeSession(home, "-work-repo", "stale", cwd, 10); + const oldChild = writeJsonl(path.join(home, ".claude/projects/-work-repo/recent/subagents/agent-old.jsonl"), [{ cwd }], 9); + const staleChild = writeJsonl(path.join(home, ".claude/projects/-work-repo/stale/subagents/agent-new.jsonl"), [{ cwd }], 2); + + assert.deepEqual(paths(list(home, ["--host", "claude", "--cwd", cwd])), [recent, staleChild, older]); + const since = new Date(now - 11 * DAY).toISOString(); + const until = new Date(now - 5 * DAY).toISOString(); + assert.deepEqual(paths(list(home, ["--host", "claude", "--cwd", cwd, "--since", since, "--until", until])), [older, oldChild, stale]); + + const text = run(home, ["--host", "claude", "--cwd", cwd]); + assert.equal(text.status, 0, text.stderr); + assert.deepEqual(text.stdout.trim().split("\n").map((line) => line.split("\t")[3]), [recent, staleChild, older]); + assert.match(text.stderr, /^3 claude transcript\(s\) for \/work\/repo from /); +}); + +test("Given Codex rollouts from several projects and days, When one cwd is listed, Then matching sessions and subagents in the window are returned by mtime", (t) => { + const home = tempHome(t); + const cwd = "/work/repo"; + const today = codexRollout(home, "2026-10-17", "today", cwd, 0.5); + const resumed = codexRollout(home, "2026-09-01", "resumed", cwd, 1); + const child = codexRollout(home, "2026-10-16", "child", cwd, 2, { subagent: { thread_spawn: { parent_thread_id: "today", depth: 1 } } }); + codexRollout(home, "2026-10-17", "other-project", "/work/repo-other", 0.5); + codexRollout(home, "2026-10-01", "too-old", cwd, 20); + writeJsonl(path.join(home, ".codex/sessions/2026/10/17/rollout-no-meta.jsonl"), [{ type: "response_item", payload: { cwd } }], 0.5); + writeJsonl(path.join(home, ".codex/sessions/archive/rollout-elsewhere.jsonl"), [{ type: "session_meta", payload: { id: "x", cwd } }], 0.5); + + const listing = list(home, ["--host", "codex", "--cwd", cwd]); + assert.deepEqual(paths(listing), [today, resumed, child]); + assert.deepEqual(listing.transcripts.map(({ kind, session, parent }) => ({ kind, session, parent })), [ + { kind: "session", session: "today", parent: null }, + { kind: "session", session: "resumed", parent: null }, + { kind: "subagent", session: "child", parent: "today" }, + ]); + assert.ok(!JSON.stringify(listing).includes(SECRET)); +}); + +test("Given unreadable files outside the scope, When either host lists, Then they are never opened", { skip: process.getuid?.() === 0 && "root ignores file modes" }, (t) => { + const home = tempHome(t); + const cwd = "/work/repo"; + const own = claudeSession(home, "-work-repo", "own", cwd, 1); + const lockedProject = claudeSession(home, "-work-repo-sibling", "theirs", "/work/repo-sibling", 1); + const codexOwn = codexRollout(home, "2026-10-17", "own", cwd, 1); + const lockedOld = codexRollout(home, "2026-09-01", "old", "/work/elsewhere", 30); + for (const file of [lockedProject, lockedOld]) chmodSync(file, 0); + + assert.deepEqual(paths(list(home, ["--host", "claude", "--cwd", cwd])), [own]); + assert.deepEqual(paths(list(home, ["--host", "codex", "--cwd", cwd])), [codexOwn]); +}); + +test("Given relocated host homes and several checkouts, When listed, Then CLAUDE_CONFIG_DIR and CODEX_HOME are read and every --cwd is matched", (t) => { + const home = tempHome(t); + const claudeDir = path.join(home, "claude-config"); + const codexDir = path.join(home, "codex-home"); + const main = writeJsonl(path.join(claudeDir, "projects/-work-repo/main.jsonl"), [{ cwd: "/work/repo" }], 1); + const worktree = writeJsonl(path.join(claudeDir, "projects/-work-repo--claude-worktrees-w/wt.jsonl"), [{ cwd: "/work/repo/.claude/worktrees/w" }], 2); + claudeSession(home, "-work-repo", "default-home", "/work/repo", 1); + const rollout = writeJsonl(path.join(codexDir, "sessions/2026/10/17/rollout.jsonl"), [{ type: "session_meta", payload: { id: "r", cwd: "/work/repo" } }], 1); + const env = { CLAUDE_CONFIG_DIR: claudeDir, CODEX_HOME: codexDir }; + + const claude = list(home, ["--host", "claude", "--cwd", "/work/repo", "--cwd", "/work/repo/.claude/worktrees/w"], env); + assert.deepEqual(paths(claude), [main, worktree]); + assert.deepEqual(claude.cwds, ["/work/repo", "/work/repo/.claude/worktrees/w"]); + assert.deepEqual(paths(list(home, ["--host", "codex", "--cwd", "/work/repo"], env)), [rollout]); +}); + +test("Given invalid arguments, When the CLI runs, Then it exits 1 with the reason", (t) => { + const home = tempHome(t); + const cases = [ + [[], /--host is required/], + [["--host", "cursor"], /host must be one of claude or codex/], + [["--host", "claude", "--since", "last week"], /--since must be YYYY-MM-DD or an ISO 8601 date-time/], + [["--host", "claude", "--until", "2026-02-30"], /--until 2026-02-30 is not a calendar date/], + [["--host", "claude", "--since", "2026-10-10", "--until", "2026-10-01"], /since must not be later than until/], + [["--host", "claude", "--host", "codex"], /may only be provided once/], + [["--host", "codex", "--cwd"], /--cwd requires a value/], + ]; + for (const [args, message] of cases) { + const result = run(home, args); + assert.equal(result.status, 1, args.join(" ")); + assert.match(result.stderr, message); + assert.equal(result.stdout, ""); + } +}); diff --git a/skills/automate-me/SKILL.md b/skills/automate-me/SKILL.md index 2e5ee6c..1f7d76e 100644 --- a/skills/automate-me/SKILL.md +++ b/skills/automate-me/SKILL.md @@ -27,9 +27,9 @@ Update mode changes the rest of the flow: ### 1. Mine their history -Use the current conversation or an authorized host thread-history tool. If local rollouts are needed, constrain candidates by real session metadata (workspace cwd, thread ID, and requested date range) before reading content. Do not scan unrelated projects. If the active transcript is unavailable, use a clearly labeled digest and report the limits of trace verification. +Use the current conversation or an authorized host thread-history tool. If local transcripts are needed, list candidates with `node /bin/transcripts.mjs`, as the shared runtime contract describes, with `--since` at the start of the mining window (in update mode, the skill's last edit), and read only the files it lists. Do not scan unrelated projects. If the active transcript is unavailable, use a clearly labeled digest and report the limits of trace verification. -Survey recent agent conversations within that scope for recurring patterns. Run multiple parallel subagents across slices of history (e.g. last 2-4 weeks, split into 3 slices so each has enough material). Set each worker's reasoning effort for the job, per the Subagents section of the **poteto-mode** skill. Each slice mining subagent reads transcripts from the workspace-scoped path the parent provides, looks for the signals below, and returns a short structured list of patterns it saw with evidence pointers. Default signals worth hunting: +Survey recent agent conversations within that scope for recurring patterns. Run multiple parallel subagents across slices of history (e.g. last 2-4 weeks, split into 3 slices so each has enough material). Set each worker's reasoning effort for the job, per the Subagents section of the **poteto-mode** skill. Each slice mining subagent reads only the transcripts in its slice of the helper's list, looks for the signals below, and returns a short structured list of patterns it saw with evidence pointers. Default signals worth hunting: - Response preferences (length, tone, format, "dumb it down" corrections) - Delegation habits (subagents, models, specialized workflows, parallelism) diff --git a/skills/correct/SKILL.md b/skills/correct/SKILL.md index 9800ff9..d1c70b8 100644 --- a/skills/correct/SKILL.md +++ b/skills/correct/SKILL.md @@ -13,7 +13,7 @@ Assume every contributor is an agent that sees only the files it opened, copies ## Find the mistake classes -First, read recent commits, reverts, review comments, agent instruction files, and comments that explain workarounds. Group the mistakes into classes. A class counts once it has happened twice. +First, read recent commits, reverts, review comments, agent instruction files, and comments that explain workarounds. Then find where the operator corrected agents in recent sessions on this repo: list those sessions with `node /bin/transcripts.mjs`, as the shared runtime contract describes, and search only the files it lists. Group the mistakes into classes. A class counts once it has happened twice. ## Fix each class at the highest level that works diff --git a/skills/recall/SKILL.md b/skills/recall/SKILL.md index 6e5be71..96212c0 100644 --- a/skills/recall/SKILL.md +++ b/skills/recall/SKILL.md @@ -13,11 +13,11 @@ Keep it tight and on-topic. Read only what the in-scope threads need, then stop. Your context lives in two records. Your own chat history holds what you did and decided. The shared record holds everything that happened around the same code under other names: the symptoms users keep reporting, the fixes that shipped and got reverted, the errors still firing in prod. That second record is what the **why** skill searches, across source control, the issue tracker, chat and issue channels, long-form docs, and error tracking. A feature with a long bug tail keeps most of its story there, so don't reconstruct it from your transcripts alone. -Use the current conversation or an authorized host thread-history tool. If local rollouts are needed, constrain candidates by real session metadata (workspace cwd, thread ID, and requested date range) before reading content. Do not scan unrelated projects. If the active transcript is unavailable, use a clearly labeled digest and report the limits of trace verification. +Use the current conversation or an authorized host thread-history tool. If local transcripts are needed, list candidates for the scope from step 2 with `node /bin/transcripts.mjs`, as the shared runtime contract describes, and read only the files it lists. Do not scan unrelated projects. If the active transcript is unavailable, use a clearly labeled digest and report the limits of trace verification. 1. Classify, then route. One specific prior chat to resume is the `session-pickup` playbook, not this. Turning habits into a durable skill is `automate-me`. A human-readable summary of your work is a different task. Recall loads working context across recent chats before you act. If the user already gave you a full state capsule (paths, branch, the change), use it and skip the mining. -2. Lock the scope before searching. Pin the window ("recent" is a real range, default the last 7 days), the topic if named, and the workspace (default the active one. Never read another project's transcripts without being asked). State the scope back. Never quietly turn "all" into "recent N". -3. Fan out across your chat history. Spawn parallel subagents on a fast, cheap model, each taking a slice of the corpus. Tell every subagent to order candidates by real modification time (`ls -t`) and never by UUID name, grep the topic first and then read only the matching chats and only their relevant regions, and skip the current chat plus obvious noise (subagent, eval, and test chats). Each returns the same schema, one block per chat: topic, the user's goal, decisions, open threads, struggles and corrections, and artifacts (PRs, tickets, branches), each citing the chat UUID. For one or two chats, skip the fan-out and search directly. The raw transcripts stay in the subagents. The main thread gets only their findings. Set each worker's reasoning effort for the job, per the Subagents section of the **poteto-mode** skill. +2. Lock the scope before searching. Pin the window ("recent" is a real range, default the last 7 days), the topic if named, and the workspace (default the active repository: this checkout plus the others `git worktree list` names, each passed as a `--cwd`. Never read another project's transcripts without being asked). State the scope back. Never quietly turn "all" into "recent N". +3. Fan out across your chat history. Spawn parallel subagents on a fast, cheap model, each taking a slice of the helper's list, which is already ordered by real modification time, never by UUID name. Tell every subagent to grep the topic first and then read only the matching chats and only their relevant regions, and skip the current chat plus obvious noise (rows of kind `subagent`, eval, and test chats). Each returns the same schema, one block per chat: topic, the user's goal, decisions, open threads, struggles and corrections, and artifacts (PRs, tickets, branches), each citing the chat UUID. For one or two chats, skip the fan-out and search directly. The raw transcripts stay in the subagents. The main thread gets only their findings. Set each worker's reasoning effort for the job, per the Subagents section of the **poteto-mode** skill. 4. Sweep the shared record whenever the topic names a feature, file, subsystem, area, or bug. This is the default, not a judgment call, and "my work on X" does not exempt it. Hand it to the **why** skill's source investigators, but steer their question from "why was this built this way" to "what's the current state, what's been tried and didn't hold, and what are users still reporting". Reuse its per-source playbooks, run the investigators in parallel with the chat-history mining, and inherit its posture: one investigator per source, null results are findings, skip an unavailable MCP and say so. Fold what comes back into the brief. Skip this step only for pure activity recall with no named target ("what did I do this week"), where your own history and live state are the entire answer. 5. Verify against live state. Take the PRs, branches, and tickets that the mining and the sweep surfaced and check them with `git` and `gh`. When the answer hinges on what an agent actually did (the tools it ran, files it read, errors it hit), read the full transcript, not just a trimmed local copy. 6. Write the brief to the contract below. Group by thread. Stay on the named topic. From e8255a4ef5358e6821164d143c089183442ddc81 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:35:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(history):=20=E5=90=88=E5=B9=B6=20j?= =?UTF-8?q?sonl=20=E6=9E=9A=E4=B8=BE=E5=B9=B6=E5=8E=BB=E6=8E=89=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=E5=AF=BC=E5=87=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收尾审查发现两处结构问题,行为不变: - .jsonl 枚举与去扩展名在三处重复,抽成 jsonlFiles(dir)。 - listTranscripts 的导出和 env/homeDir 注入参数没有调用方,测试走 CLI; 去掉这层接口,宿主目录在各自的列举函数里解析。 Co-Authored-By: Claude Opus 5.5 --- bin/transcripts.mjs | 51 ++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/bin/transcripts.mjs b/bin/transcripts.mjs index ff6a773..f1a0aa3 100644 --- a/bin/transcripts.mjs +++ b/bin/transcripts.mjs @@ -30,12 +30,15 @@ function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function statOrNull(file) { - return statSync(file, { throwIfNoEntry: false }) ?? null; +function entries(dir) { + return statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? readdirSync(dir, { withFileTypes: true }) : []; } -function entries(dir) { - return statOrNull(dir)?.isDirectory() ? readdirSync(dir, { withFileTypes: true }) : []; +// The .jsonl files directly inside dir; stem is the name without the extension. +function jsonlFiles(dir) { + return entries(dir) + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => ({ file: path.join(dir, entry.name), stem: entry.name.slice(0, -".jsonl".length) })); } // Splits only at newline bytes, which never occur inside a UTF-8 sequence, so each line decodes whole. @@ -71,7 +74,7 @@ function parseRecord(line) { } } -function transcript(file, stat, fields) { +function transcript({ file, stat, ...fields }) { return { ...fields, path: file, mtime: new Date(stat.mtimeMs).toISOString(), size: stat.size }; } @@ -85,27 +88,21 @@ function claudeSessionCwd(file, cwds) { return null; } -function listClaude({ cwds, inWindow, env, homeDir }) { - const root = path.join(env.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude"), "projects"); +function listClaude(cwds, inWindow) { + const root = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude"), "projects"); const found = []; for (const dir of new Set([...cwds].map((cwd) => path.join(root, claudeProjectSlug(cwd))))) { - for (const entry of entries(dir)) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - const session = entry.name.slice(0, -".jsonl".length); - const file = path.join(dir, entry.name); - const subagentDir = path.join(dir, session, "subagents"); + for (const { file, stem: session } of jsonlFiles(dir)) { const candidates = [ - { file, fields: { kind: "session", session, parent: null } }, - ...entries(subagentDir) - .filter((sub) => sub.isFile() && sub.name.endsWith(".jsonl")) - .map((sub) => ({ file: path.join(subagentDir, sub.name), fields: { kind: "subagent", session: sub.name.slice(0, -".jsonl".length), parent: session } })), + { file, kind: "session", session, parent: null }, + ...jsonlFiles(path.join(dir, session, "subagents")).map((sub) => ({ file: sub.file, kind: "subagent", session: sub.stem, parent: session })), ] .map((candidate) => ({ ...candidate, stat: statSync(candidate.file) })) .filter((candidate) => inWindow(candidate.stat.mtimeMs)); if (!candidates.length) continue; const cwd = claudeSessionCwd(file, cwds); if (cwd === null) continue; - for (const candidate of candidates) found.push(transcript(candidate.file, candidate.stat, { ...candidate.fields, cwd })); + for (const candidate of candidates) found.push(transcript({ ...candidate, cwd })); } } return found; @@ -116,10 +113,7 @@ function* codexRollouts(root) { for (const year of numbered(root, 4)) { for (const month of numbered(path.join(root, year.name), 2)) { for (const day of numbered(path.join(root, year.name, month.name), 2)) { - const dir = path.join(root, year.name, month.name, day.name); - for (const entry of entries(dir)) { - if (entry.isFile() && entry.name.endsWith(".jsonl")) yield path.join(dir, entry.name); - } + yield* jsonlFiles(path.join(root, year.name, month.name, day.name)).map(({ file }) => file); } } } @@ -132,8 +126,8 @@ function firstRecord(file) { // Codex keeps no per-project directory, so a rollout's cwd is in its first record (session_meta). // The mtime check comes first: only rollouts inside the window are opened, and only that first line. -function listCodex({ cwds, inWindow, env, homeDir }) { - const root = path.join(env.CODEX_HOME || path.join(homeDir, ".codex"), "sessions"); +function listCodex(cwds, inWindow) { + const root = path.join(process.env.CODEX_HOME || path.join(homedir(), ".codex"), "sessions"); const found = []; for (const file of codexRollouts(root)) { const stat = statSync(file); @@ -143,7 +137,9 @@ function listCodex({ cwds, inWindow, env, homeDir }) { if (!meta || !cwds.has(meta.cwd)) continue; const subagent = isObject(meta.source) && isObject(meta.source.subagent) ? meta.source.subagent : null; const parent = subagent?.thread_spawn?.parent_thread_id; - found.push(transcript(file, stat, { + found.push(transcript({ + file, + stat, kind: subagent ? "subagent" : "session", session: typeof meta.id === "string" ? meta.id : null, parent: typeof parent === "string" ? parent : null, @@ -169,12 +165,11 @@ function matchableCwds(cwds) { return forms; } -export function listTranscripts({ host, cwds, since, until, env = process.env, homeDir = homedir() }) { +function listTranscripts({ host, cwds, since, until }) { if (!HOSTS.includes(host)) throw new Error(`host must be one of ${HOSTS.join(" or ")}`); - if (!cwds.length) throw new Error("at least one cwd is required"); if (since > until) throw new Error("since must not be later than until"); - const options = { cwds: matchableCwds(cwds), inWindow: (mtime) => mtime >= since && mtime <= until, env, homeDir }; - const found = host === "claude" ? listClaude(options) : listCodex(options); + const list = host === "claude" ? listClaude : listCodex; + const found = list(matchableCwds(cwds), (mtime) => mtime >= since && mtime <= until); found.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.path.localeCompare(b.path)); return { host,