diff --git a/AGENTS.md b/AGENTS.md index 53ad41f8b..17e4bfb05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +- aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md diff --git a/CLAUDE.md b/CLAUDE.md index 410b0253c..c447680b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +- aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md diff --git a/aidd_docs/memory/README.md b/aidd_docs/memory/README.md index 23d09e2e0..a8330f114 100644 --- a/aidd_docs/memory/README.md +++ b/aidd_docs/memory/README.md @@ -32,6 +32,7 @@ Read on demand: - [internal/decisions/a-price-table-is-the-destination-s.md](internal/decisions/a-price-table-is-the-destination-s.md) - [internal/decisions/measurement-may-reach-a-hosted-destination.md](internal/decisions/measurement-may-reach-a-hosted-destination.md) +- [internal/decisions/one-run-journal-per-clone.md](internal/decisions/one-run-journal-per-clone.md) ## Maintaining it diff --git a/aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md b/aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md new file mode 100644 index 000000000..058e35aac --- /dev/null +++ b/aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md @@ -0,0 +1,35 @@ +# One run journal per clone, under its common git directory + +- Date: 2026-10-09 +- Status: Accepted — closes the open choice in #932, amends the per-worktree default kept after #693 +- Decided by: proposed by Vincent Menard, for a maintainer to confirm or overturn + +## Context + +The hook wrote each session's journal at the root of the checkout it ran in, `/aidd_docs/runs/`. The CLI read only the journal of the checkout it ran in, and the sink is filled only through that journal. `aidd telemetry on` git-ignores `aidd_docs/runs/`, and `git worktree remove` deletes a worktree whose only extra content is ignored without needing `--force`. A session an agent ran in its own worktree was therefore missing from every report outside that worktree, and lost for good once the worktree was removed. Agent runners give each agent its own worktree, so this was the common case. + +The per-worktree default had two sound reasons: nothing should write into another branch's working tree, and a bare clone has no main working tree to write into. + +## Decision + +1. The run journal of a clone lives in one directory, `/aidd/runs/`, shared by every worktree of that clone. It is outside every working tree, so it is never committed, dirties no branch, and survives `git worktree remove`. A bare clone has a common dir too. +2. `AIDD_RUNS_DIR` still replaces that location entirely. +3. Journals already written to a live checkout's `aidd_docs/runs/` are still read, never written. The copy under the common git directory wins when one session has both. +4. `worktree_id` stays on the `session_start` line. The report gains no worktree axis: two worktrees that worked on one task add up. Only the cumulative figure matters: a worktree is a technical artefact an agent runner names, and the task, backlog, flow and agent axes already answer what a piece of work cost. The 2026-08-31 decision requires an argument for a new axis; there is none. + +## Alternatives + +- **Keep one journal per worktree and gather them at report time.** Solves the report, not the removal: the journal still goes with the worktree. +- **Write into the main working tree.** Impossible for a bare clone, and dirties a checkout on another branch. +- **Copy each worktree's journal into the main one.** Two copies of one session diverge, and the copy only happens if someone reports before the removal. + +## Consequences + +- A worktree removed before this change, never read before, stays lost. +- The hook ships in the plugin and the reader in the CLI, released apart. A newer CLI reads both locations; an older CLI with a newer plugin sees no new session and cannot forget it. The plugin README therefore asks for the CLI to be updated first. +- A session the previous plugin opened keeps its old run file; lines the new plugin writes later in that same session find no file under the new location and are dropped until the next session starts. +- A session resumed after the update starts a second run file under the new location; that copy wins over the old one, so the part of the session journalled before the update loses its attribution. +- A clone made with `--separate-git-dir`, or a submodule, has no `.git` directory to name its main working tree, so from one of its linked worktrees the main checkout's pre-move journal is neither read nor offered to forget. New journals are unaffected. +- The two clones of one remote keep two journals; the sink and the project axis already pool them. +- The backlog axis still resolves task folders in the checkout running the report, and catch-up still follows that checkout's switch. Both are outside this decision. +- Hook and CLI spell the location separately (`repo.cjs`, `cli/src/kernel/paths.ts`); a parity test holds them together. diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index 80099b5b7..cc19b7a0e 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -26,7 +26,7 @@ What this project is, the problem it solves, and its domain language. The non-de | Memory | the bank under `aidd_docs/memory/`, loaded every session | | Marketplace | `.claude-plugin/marketplace.json`, the plugin registry | | Concern | what a plugin owns; decides where a capability lives | -| Run journal | what a session did, appended by a hook under `aidd_docs/runs/` | +| Run journal | what a session did, appended by a hook under the clone's common git directory (`/aidd/runs/`) | | Promote | sending `next` to `main`, which opens the release | ## Key features diff --git a/aidd_docs/runs/README.md b/aidd_docs/runs/README.md index 2be3e375f..2b2918b65 100644 --- a/aidd_docs/runs/README.md +++ b/aidd_docs/runs/README.md @@ -1,6 +1,6 @@ # aidd_docs/runs -Where the run journal's records land once AIDD telemetry is turned on. This directory being present or committed is **not the permission**. The single authoritative switch is `.aidd/config.json`'s `telemetry.enabled`, read by `plugins/aidd-telemetry/hooks/journal.js` at the point of every write, never cached across a session. With that switch on, `aidd_docs/runs/` is created on demand if it does not already exist; with it off, no record lands here regardless of whether this directory exists. Records are ignored by git (see `.gitignore`), so cloning the repository never carries anyone's session history. +The run journal's contract. Records no longer land in this directory: once AIDD telemetry is turned on, the hook writes them under the clone's common git directory, `/aidd/runs/` (for a plain checkout, `.git/aidd/runs/`), shared by every worktree of the clone and outside every working tree, so `git worktree remove` never takes them and no commit can carry them. `AIDD_RUNS_DIR` replaces that location entirely. The single authoritative switch is still `.aidd/config.json`'s `telemetry.enabled` in the checkout a session runs in, read by `plugins/aidd-telemetry/hooks/journal.cjs` at the point of every write, never cached across a session; the directory is created on demand. Journals an earlier version wrote into a live checkout's `aidd_docs/runs/` are still read by the CLI, never written; they stay git-ignored (see `.gitignore`). Why: [one run journal per clone](../memory/internal/decisions/one-run-journal-per-clone.md). ## Shape @@ -59,7 +59,7 @@ through a shell command or an `apply_patch` becomes visible at all — and it ca principle, catch a file something else on the machine wrote in the same window. A reader that must not risk that filters on this field. -`worktree_id` is git's own name for the linked worktree the session ran in, and `worktree_repo_id` names the repository those worktrees share — read from one `git rev-parse`, never from an agent runner's environment variable, which names that runner's concept rather than the repository's. A plain checkout, the common case, carries **neither key at all**: absent, never `null` and never `""`, because an empty string would gather every plain checkout into one group as though they were the same worktree. +`worktree_id` is git's own name for the linked worktree the session ran in, and `worktree_repo_id` names the repository those worktrees share — read from one `git rev-parse`, never from an agent runner's environment variable, which names that runner's concept rather than the repository's. A plain checkout, the common case, carries **neither key at all**: absent, never `null` and never `""`, because an empty string would gather every plain checkout into one group as though they were the same worktree. Every worktree of a clone writes into the same directory, so these two keys are what keeps their sessions apart. Neither `file_written` nor `task_declared` ever carries a `task_id`: task identity is a derivation from the path, and derivations belong to whatever reads the log, not to the hook that writes it. `task_declared` differs from `file_written` in what it takes as evidence, not in what it stores: a mention in a tool call's arguments (a read, an edit, a shell command line) rather than a payload naming a write outright - the move that reaches a task on a tool whose payload never hands over a written path at all. A reader turns a run of `task_declared` lines into a bounded interval, closed by whichever of a later declaration or a `turn_end` comes next; see `aidd_docs/product/metrics-contract.md`'s "Attributing records to a task". diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/brainstorm.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/brainstorm.md new file mode 100644 index 000000000..c7e501099 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/brainstorm.md @@ -0,0 +1,27 @@ +# A worktree session outlives the worktree's removal + +Refines [#932](https://github.com/ai-driven-dev/framework/issues/932). + +Whoever asks what a task cost must get the answer even when the work was done by an agent in a linked worktree, and even when that worktree has already been removed. Today the journal is written inside the worktree's tree, the report reads only the current checkout's journal, and `git worktree remove` deletes a journal that is merely ignored without `--force` (measured with git 2.55.0.windows.5). The session then never reaches the sink, and the task reads `no-journal` or does not exist at all. #631 asks for the link to the task and forbids losing a session silently. This is that defect. + +The conclusion written in the hook ("one journal per worktree, at the worktree's root") is amended, along with its claim that `worktree_id` alone serves cross-worktree joining. Its reasons still hold: nothing writes into another branch's tree, and a bare clone has no main working tree. The clone's journal therefore lives in one place, under the clone's common git directory, outside every working tree and outside any worktree's own git directory. `git worktree remove` does not delete it. It is not a checkout file, so it is never committed and dirties no branch. Every reader (report, local read, diagnosis including its unrecognised-records evidence, forget) uses that place, and `AIDD_RUNS_DIR` still replaces it entirely. + +## What Is Clear + +- A report run from any checkout of the clone counts the sessions of all its worktrees, past ones included, in the totals and in the task or flow. Journals from two clones of the same remote are not gathered together; the totals and the project axis already pool them through the machine's sink. +- Writing happens during the session. A report run only after the removal comes too late. +- `worktree_id` and `worktree_repo_id` stay on the `session_start` line, absent on a plain checkout. The report gains no worktree axis: two worktrees that touched the same task add up their cost. Only the cumulative figure matters: a worktree is a technical artefact an agent runner names, and the task, backlog, flow and agent axes already answer what a piece of work cost. #932 is amended accordingly; `worktree_id` serves only to explain an attribution from the journal. +- One copy is authoritative. No second journal in the worktree's `aidd_docs/runs/`: the two would diverge. +- Journals already written in a worktree that still exists are read alongside the new place, otherwise today's sessions stay invisible. New lines no longer go there. +- A worktree removed before this change, with no earlier read, stays lost. Transcripts are not enough: without a journal, nothing names the session. +- The working checkout's switch still decides whether to write. The person's refusal always wins. The new directory gets the same owner-only permissions `aidd_docs/runs/` gets today; the `.gitignore` entry has no purpose there. +- The sink stays per machine. Task attribution does not move into it: it is still derived from the journal at report time. +- Catch-up into the sink stays gated by the switch of the checkout running the report: measurement off stores nothing, by design (`report-cost-use-case.ts`). Checkouts of one clone share the committed switch in practice. + +## Still Open + +- The backlog axis resolves a task folder in the current checkout's tree, so a session whose task folder exists only on another worktree's branch reads `none`. Tracked as a defect on #975, outside this change. + +## Next Move + +Nothing left to decide here: the backlog-axis defect lives on #975. The worktree axis is settled: the report keeps the cumulative figures only, and #932 is amended to say so. diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-1.md new file mode 100644 index 000000000..f3d07e876 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-1.md @@ -0,0 +1,260 @@ +--- +status: done +--- + +# Instruction: The hook writes under the common git directory + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── plugins/aidd-telemetry/hooks/lib/ +│ └── repo.cjs ✏️ runsDir(repoRoot, commonDir); resolveRunsDir passes the common dir; decision comment amended +└── scripts/__tests__/ + ├── aidd-telemetry-journal.test.js ✏️ runsDirOf via git; sessionStartLineIn by session id; new location tests + ├── aidd-telemetry-runs-dir.test.js ✏️ default-location assertions point at the common dir + ├── aidd-telemetry-file-writes.test.js ✏️ seeded run files move to /.git/aidd/runs + ├── aidd-telemetry-plugin-version.test.js ✏️ reads run files from /.git/aidd/runs + ├── opencode-plugin.test.js ✏️ runsDirOf points at /.git/aidd/runs + └── aidd-telemetry-journal-perf-harness.js ✏️ seeded dir moves to /.git/aidd/runs +``` + +Nothing else in `plugins/aidd-telemetry/hooks/` changes: every writer (`record.cjs`, `file-writes.cjs`, `step-starts.cjs`, `step-ends.cjs`, `task-declared.cjs`) already takes its directory from `resolveRunsDir(...).dir` or `resolveWriteTarget(...).dir`. Do not edit them. + +## User Journey + +```mermaid +flowchart TD + A[Session starts in a linked worktree] --> B[resolveRunsDir: getRepoLocation gives repoRoot and the common git dir] + B --> C{AIDD_RUNS_DIR set?} + C -->|yes| D[write into AIDD_RUNS_DIR] + C -->|no| E{common dir known?} + E -->|yes| F[write into common-dir/aidd/runs] + E -->|no| G[write into repoRoot/aidd_docs/runs] + F --> H[git worktree remove: the journal is still there] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + git init a repo, empty commit, git worktree add a linked worktree, telemetry switched on in it => two checkouts of one clone: 5: system + section Happy path + replay SessionStart with cwd in the linked worktree => one run file in main/.git/aidd/runs, none in the worktree: 5: system + replay SessionStart with cwd in the main checkout => its run file lands in the same main/.git/aidd/runs: 5: system + git worktree remove the linked worktree without --force => exit 0 and the worktree's run file still exists: 5: system + section Edge case - AIDD_RUNS_DIR set + AIDD_RUNS_DIR names a directory => replay SessionStart => the file lands there and nowhere under .git: 1: system + section Edge case - no common dir + runsDir called with an empty common dir => runsDir("/repo", "") => /repo/aidd_docs/runs: 1: system + section Teardown + remove temp repos and worktrees => tmp clean: 5: system +``` + +## Tasks to do + +### `1)` Pin the new location in tests first + +> Make the suite describe the new location before the code moves, and watch it fail. + +1. In `scripts/__tests__/aidd-telemetry-journal.test.js`, replace the body of `runsDirOf(repo)` (around line 663) with a git lookup, so it works for a linked worktree too: + + ```js + // Where the hook writes: under the clone's common git directory, which every worktree of + // one clone shares. Asked of git rather than joined by hand, so a linked worktree resolves + // to its main repository's directory exactly as the hook does. + function runsDirOf(repo) { + const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: repo, + encoding: "utf8", + env: CLEAN_ENV, + }).trim(); + return path.join(path.resolve(repo, commonDir), "aidd", "runs"); + } + ``` + +2. In the same file add, right below it: + + ```js + // The pre-move location. Still created by some fixtures to prove it is ignored, never written. + function legacyRunsDirOf(repo) { + return path.join(repo, "aidd_docs", "runs"); + } + ``` + +3. In `makeTempRepo` (around line 647-649) replace `path.join(dir, "aidd_docs", "runs")` with `legacyRunsDirOf(dir)`. Keep the `withRunsDir` option and its default. +4. In `makeWorktree` (around line 3503) replace `fs.mkdirSync(runsDirOf(worktree), ...)` with `fs.mkdirSync(legacyRunsDirOf(worktree), { recursive: true })`. +5. In `resolveRunsDir writes a worktree's journal under the worktree, not the main checkout` (around line 618): + - rename the test to `resolveRunsDir writes a worktree's journal under the clone's common git directory, shared with the main checkout`; + - replace `fs.mkdirSync(runsDirOf(worktree), ...)` with `fs.mkdirSync(legacyRunsDirOf(worktree), { recursive: true })`; + - replace the last two assertions with: + + ```js + assert.equal(canonicalPath(target.repoRoot), canonicalPath(worktree)); + assert.equal(path.basename(target.dir), "runs"); + assert.equal(path.basename(path.dirname(target.dir)), "aidd"); + assert.equal( + canonicalPath(path.dirname(path.dirname(target.dir))), + canonicalPath(path.join(main, ".git")), + ); + ``` + + `canonicalPath` calls `fs.realpathSync.native`, which throws `ENOENT` on a path that does not exist. Nothing has written yet, so neither `
/.git/aidd/runs` nor `
/.git/aidd` exists: only `
/.git` may be canonicalised. Compare the two last segments by name instead. The three assertions together already prove the directory is not under the worktree, so no `notEqual` is needed. +6. Change `sessionStartLineIn(dir)` (around line 3508) to take the session id, because two worktrees now share one directory: + + ```js + function sessionStartLineIn(dir, sessionId) { + const file = readRunFiles(runsDirOf(dir)).find((f) => path.basename(f).endsWith(`__${sessionId}.jsonl`)); + assert.ok(file, `no run file for ${sessionId} under ${runsDirOf(dir)}`); + return readLines(file)[0]; + } + ``` + + Update every caller to pass the session id it replayed: `sessionStartLineIn(worktree, "wt-named-session")`, `sessionStartLineIn(first, "wt-alpha")`, `sessionStartLineIn(second, "wt-beta")`, and any other caller (`grep -n "sessionStartLineIn(" scripts/__tests__/aidd-telemetry-journal.test.js`), each with the `sessionId` used in its own `makePayload`. +7. Rename the test at line 575 to `runsDir defaults to /aidd/runs, and to /aidd_docs/runs only without a common dir, when AIDD_RUNS_DIR is unset` and replace its body's assertion with three: + + ```js + assert.equal(runsDir("/repo", "/repo/.git"), path.join("/repo/.git", "aidd", "runs")); + assert.equal(runsDir("/repo", ""), path.join("/repo", "aidd_docs", "runs")); + assert.equal(runsDir("/repo"), path.join("/repo", "aidd_docs", "runs")); + ``` + + In the `AIDD_RUNS_DIR` test right after it (around line 582), call `runsDir("/repo", "/repo/.git")` instead of `runsDir("/repo")`; it must still return `/custom/runs`. +8. Test `a session writes nothing and exits 0 when .aidd/config.json is absent, even with aidd_docs/runs/ present` (line 776): keep it, add `assert.equal(fs.existsSync(runsDirOf(repo)), false);` after the existing assertion. +9. Test `aidd_docs/runs/ is no longer a permission: ...` (line 789): rename to `the run journal directory is no permission: a switched-on session creates it on demand when it does not exist yet`. Its body already uses `runsDirOf`, keep it. +10. Test `a session writes exactly one file directly under aidd_docs/runs/ when opted in, ...` (line 968): rename `aidd_docs/runs/` to `the common git directory's aidd/runs/` in its title only. +11. Test `two repositories with different remotes each write into their own aidd_docs/runs/, ...` (line 1429): rename `aidd_docs/runs/` to `common git directory` in its title only. +12. Test `in a real temporary git repo: the marker files are tracked, a record file is not, ...` (line 2282). The marker files stay in the legacy directory; the record moves: + - `const runsPath = runsDirOf(repo);` becomes `const markersPath = legacyRunsDirOf(repo);` and the three following lines use `markersPath`; + - after the `replayIn(...)` and its status assertion, read records with `const recordFiles = readRunFiles(runsDirOf(repo));` and assert `recordFiles.length === 1` with message `"the record did not land under the common git directory"`; set `const recordPath = recordFiles[0];`; + - delete the `git check-ignore` assertion (a file under `.git` is not part of the working tree, so `check-ignore` does not apply) and replace it with `assert.equal(fs.readdirSync(markersPath).filter((f) => f.endsWith(".jsonl")).length, 0, "no record may land in aidd_docs/runs/ any more");`; + - keep the `git add -A` / `status --porcelain` clean-tree assertion and the final `existsSync(recordPath)` assertion. +13. Add these new tests at the end of the worktree block (after `a session in a plain checkout carries no worktree field at all ...`): + + ```js + test("a session in a linked worktree writes into the main repository's .git/aidd/runs, never into the worktree", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-shared.git" }); + const worktree = makeWorktree(repo, "shared"); + try { + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: worktree, sessionId: "wt-shared", event: "SessionStart" })); + }); + const shared = readRunFiles(path.join(repo, ".git", "aidd", "runs")); + assert.equal(shared.filter((f) => path.basename(f).endsWith("__wt-shared.jsonl")).length, 1); + assert.equal(readRunFiles(legacyRunsDirOf(worktree)).length, 0, "nothing may land in the worktree's own aidd_docs/runs/"); + } finally { + cleanup(repo, worktree); + } + }); + + test("the main checkout and a linked worktree write into one directory", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-one-dir.git" }); + const worktree = makeWorktree(repo, "onedir"); + try { + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: repo, sessionId: "main-session", event: "SessionStart" })); + processPayload(makePayload({ cwd: worktree, sessionId: "wt-session", event: "SessionStart" })); + }); + assert.equal(canonicalPath(runsDirOf(repo)), canonicalPath(runsDirOf(worktree))); + const names = readRunFiles(runsDirOf(repo)).map((f) => path.basename(f)); + assert.ok(names.some((n) => n.endsWith("__main-session.jsonl"))); + assert.ok(names.some((n) => n.endsWith("__wt-session.jsonl"))); + } finally { + cleanup(repo, worktree); + } + }); + + test("git worktree remove, without --force, leaves the worktree's journal in place", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-removed.git" }); + const worktree = makeWorktree(repo, "removed"); + try { + // makeWorktree leaves an untracked .aidd/config.json, which would make + // `git worktree remove` refuse. Ignore it the way a real project's .gitignore would; + // info/exclude lives in the common dir, so it applies to every worktree. + fs.mkdirSync(path.join(repo, ".git", "info"), { recursive: true }); + fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), "\n.aidd/\naidd_docs/\n"); + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: worktree, sessionId: "wt-removed", event: "SessionStart" })); + }); + execFileSync("git", ["worktree", "remove", worktree], { cwd: repo, env: CLEAN_ENV }); + assert.equal(fs.existsSync(worktree), false, "the worktree itself must be gone"); + const names = readRunFiles(path.join(repo, ".git", "aidd", "runs")).map((f) => path.basename(f)); + assert.ok(names.some((n) => n.endsWith("__wt-removed.jsonl")), "the journal must outlive the worktree"); + } finally { + cleanup(repo); + } + }); + ``` + + `cleanup(repo)` alone is right in the last test: the worktree directory no longer exists. Check `cleanup` (`grep -n "^function cleanup" scripts/__tests__/aidd-telemetry-journal.test.js`) tolerates being handed only the repo; it takes a rest list, so it does. +14. In the other test files, every repository is a plain `git init`, so the common dir is `/.git`. Replace exactly: + - `scripts/__tests__/aidd-telemetry-file-writes.test.js` lines ~104 and ~195: `path.join(repo, "aidd_docs", "runs")` becomes `path.join(repo, ".git", "aidd", "runs")`; + - `scripts/__tests__/aidd-telemetry-plugin-version.test.js`: in `readRunFileLines` (line ~199) the same replacement; in `makeTempRepo` (line ~179) **replace** `fs.mkdirSync(path.join(dir, "aidd_docs", "runs"), { recursive: true });` with `fs.mkdirSync(path.join(dir, ".git", "aidd", "runs"), { recursive: true });` (do not delete it); + - `scripts/__tests__/opencode-plugin.test.js`: `runsDirOf` (line ~78) returns `path.join(repo, ".git", "aidd", "runs")`; in `makeInstalledRepo` (line ~40) **replace** the `aidd_docs/runs` mkdir line with `fs.mkdirSync(path.join(repo, ".git", "aidd", "runs"), { recursive: true });` (do not delete it). Several tests call `readRunLines(repo)` before anything is written, or after an event that must write nothing, and expect `[]`. `readRunLines` calls `fs.readdirSync`, which throws `ENOENT` on a missing directory. The fixture used to pre-create `aidd_docs/runs` for exactly that reason, and it must now pre-create the new directory. `git init` creates `.git`, so the recursive mkdir is enough; + - `scripts/__tests__/aidd-telemetry-journal-perf-harness.js` line ~33: `path.join(repo, "aidd_docs", "runs")` becomes `path.join(repo, ".git", "aidd", "runs")`. +15. `scripts/__tests__/aidd-telemetry-runs-dir.test.js`, test `AIDD_RUNS_DIR overrides where runs are written`: rename `defaultRunsDir` to `legacyRunsDir`, and add after the existing `defaultWritten` assertion: + + ```js + assert.equal(fs.existsSync(path.join(repo, ".git", "aidd", "runs")), false, "the common git directory was used anyway"); + ``` + +16. Run the scripts suite wrapped. The new and changed tests must fail because the hook still writes to `aidd_docs/runs/`. Record which ones fail. + +### `2)` Move the write location in the hook + +> One function decides the directory; give it the common dir. + +1. In `plugins/aidd-telemetry/hooks/lib/repo.cjs`, replace `runsDir` (around line 203-206) with: + + ```js + // `AIDD_RUNS_DIR` overrides outright. Otherwise the clone's common git directory: shared by + // every worktree of one clone, outside every working tree, and never touched by + // `git worktree remove`. Without a common dir, the checkout's own root, as before. + // The directory existing is not a second gate. + const RUNS_UNDER_GIT_DIR = ["aidd", "runs"]; + + function runsDir(repoRoot, commonDir) { + if (process.env.AIDD_RUNS_DIR) return process.env.AIDD_RUNS_DIR; + if (typeof commonDir === "string" && commonDir) return path.join(commonDir, ...RUNS_UNDER_GIT_DIR); + return path.join(repoRoot, "aidd_docs", "runs"); + } + ``` + +2. In `resolveRunsDir` change `dir: runsDir(location.repoRoot)` to `dir: runsDir(location.repoRoot, location.gitDir)`. `location.gitDir` is already the absolute common dir (`getRepoLocation` sets it from `--git-common-dir`); do not change `getRepoLocation`. +3. Replace the comment block above `resolveRunsDir` (the one starting `// A decision, not an inherited default: a worktree keeps its own journal`) with: + + ```js + // A decision, not an inherited default: one journal per clone, under the common git + // directory, never at a worktree's root. A worktree's root is deleted by + // `git worktree remove` with an ignored journal inside it, which lost every session an + // agent ran there. The earlier reasons still hold and are what rules out the main working + // tree: a bare clone has none, and writing into it from another worktree dirties a + // checkout on a different branch. `worktreeFields` still names the worktree on + // `session_start`, so the shared journal keeps sessions apart. + // Recorded in aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md. + ``` + +4. Do not touch `tightenOwnedDir`, `tightenOwnedFile`, `PRIVATE_DIR_MODE` or the `module.exports` list: `runsDir` stays exported under the same name (`journal.cjs` re-exports it). +5. Run the wrapped scripts suite: all green. Then `pnpm exec lefthook run pre-commit`: green. + + Failures that do not come from this phase, and must not block it: + - **The perf test `turn-end's and file-written's in-process work stay under 200ms at p95 ...` (`aidd-telemetry-journal.test.js`):** it depends on the machine. Measured on 2026-10-09 on this Windows machine, the turn-end p95 was 326.8 ms *without* this phase's changes, and 228 to 319 ms with them. If it fails, measure the baseline: `git stash push -- plugins/aidd-telemetry/hooks/lib/repo.cjs scripts/__tests__/aidd-telemetry-journal-perf-harness.js`, then `node scripts/__tests__/aidd-telemetry-journal-perf-harness.js`, then `git stash pop`. If the baseline is also over 200 ms, write both numbers in the report and continue. If only the changed tree is over, stop and report. + - **`check-markdown-links.test.js` (3 repository-scan tests) and `dev-sync.test.js` (`managed OpenCode reload ...`):** they fail because of the staged change to `aidd_docs/memory/README.md` and because of the environment, not because of the telemetry code. Neither file reads the run journal. Mention them in the report and continue. Never fix them in this plan. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Before task 2, the new and renamed tests fail because records still land in `aidd_docs/runs/`. | +| 2 | A session in a linked worktree writes its run file to `
/.git/aidd/runs/`, and nothing lands in the worktree's `aidd_docs/runs/`. | +| 2 | The main checkout and a linked worktree of one clone write into the same directory. | +| 2 | After `git worktree remove` without `--force`, the removed worktree's run file still exists. | +| 2 | With `AIDD_RUNS_DIR` set, the file lands there and nothing is created under `.git/aidd/runs`. | +| 2 | `runsDir("/repo", "")` and `runsDir("/repo")` still answer `/repo/aidd_docs/runs`. | +| 2 | The wrapped scripts suite and the pre-commit hook pass. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-2.md new file mode 100644 index 000000000..84dbc69dd --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-2.md @@ -0,0 +1,243 @@ +--- +status: done +--- + +# Instruction: The CLI resolves the same directory and the legacy ones + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/kernel/ + │ ├── paths.ts ✏️ resolvedRunsDir → common dir; new legacyRunsDirs + │ └── reading/ + │ └── git-common-dir.ts ✅ gitCommonDirAbove, worktreeRootsOf (file reads, no git spawn) + └── tests/ + ├── kernel/reading/git-common-dir.unit.test.ts ✅ plain, linked, bare-clone worktree, broken pointer, outside + ├── kernel/paths.unit.test.ts ✏️ resolvedRunsDir and legacyRunsDirs cases + ├── helpers/telemetry-journal-hook.ts ✏️ declare resolveRunsDir on the hook's repo module + └── integration/run-journal-location-agrees.integration.test.ts ✅ hook (git rev-parse) and CLI (files) agree +``` + +## User Journey + +```mermaid +flowchart TD + A[resolvedRunsDir projectRoot] --> B{AIDD_RUNS_DIR?} + B -->|set| C[AIDD_RUNS_DIR] + B -->|unset| D[gitCommonDirAbove projectRoot] + D -->|null| E[repositoryRootAbove/aidd_docs/runs] + D -->|commonDir| F[commonDir/aidd/runs] + G[legacyRunsDirs projectRoot] --> H{AIDD_RUNS_DIR set or no commonDir?} + H -->|yes| I[empty list] + H -->|no| J[current root, main root, each live linked worktree root → root/aidd_docs/runs, existing only, deduplicated] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + real git repo with one commit, one linked worktree, one bare clone with a worktree, telemetry switched on in each => fixtures ready: 5: system + section Happy path + gitCommonDirAbove from main, from a subdirectory, from the linked worktree => the same /.git: 5: system + resolvedRunsDir from the linked worktree => /.git/aidd/runs: 5: system + legacyRunsDirs from the main checkout with aidd_docs/runs in main and in the worktree => both, current checkout first: 5: system + hook resolveRunsDir and CLI resolvedRunsDir from the same checkout => the same directory: 5: system + section Edge case - outside a checkout + no .git above => resolvedRunsDir(dir) => dir/aidd_docs/runs and legacyRunsDirs empty: 1: system + section Edge case - AIDD_RUNS_DIR + AIDD_RUNS_DIR set => resolvedRunsDir => that value and legacyRunsDirs empty: 1: system + section Edge case - removed worktree + worktree deleted from disk but still registered => legacyRunsDirs => it is skipped: 1: system + section Teardown + remove temp dirs, unset AIDD_RUNS_DIR => baseline: 5: system +``` + +## Tasks to do + +### `1)` Find the common git directory from files + +> Answer what `git rev-parse --git-common-dir` answers, without spawning git. + +1. Write `cli/tests/kernel/reading/git-common-dir.unit.test.ts` first (vitest, temp dirs via `mkdtemp(join(tmpdir(), "aidd-common-dir-"))`, real `git` through `execFileSync("git", [...], { cwd, env: environmentWithoutGitVariables(process.env) })` imported from `../../../src/runtime/git/git-environment.js`). Compare paths with `realpathSync.native` on both sides and `samePath` from `src/kernel/paths.ts`. Cases: + - plain repo `r` (git init, `git commit --allow-empty -m seed` with `-c user.email=t@e -c user.name=t`): `gitCommonDirAbove(r)` and `gitCommonDirAbove(join(r, "a", "b"))` (create the subdir) both name `r/.git`; + - linked worktree `w` (`git worktree add -q ` from `r`): `gitCommonDirAbove(w)` names `r/.git`; + - bare clone: `git clone -q --bare r b.git`, then `git -C b.git worktree add -q `: `gitCommonDirAbove(bw)` names `b.git`; + - broken pointer: a dir whose `.git` file contains `gitdir: /does/not/exist/.git/worktrees/x`: answers `resolve(dir, "/does/not/exist/.git/worktrees/x")` (the git dir itself, since no `commondir` file can be read); + - outside any checkout (a fresh temp dir): answers `null`; + - `worktreeRootsOf(r/.git)` answers `[r, w]` in that order; after `rm -rf w` (no `git worktree prune`), answers `[r]`; + - `worktreeRootsOf(b.git)` answers `[bw]` (a bare clone has no main working tree). + Run it: it fails because the module does not exist. +2. Create `cli/src/kernel/reading/git-common-dir.ts`: + + ```ts + import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; + import { basename, dirname, join, resolve } from "node:path"; + import { repositoryRootAbove } from "./repository-root.js"; + + const GIT_DIR_NAME = ".git"; + const GITDIR_POINTER = /^gitdir:\s*(.+?)\s*$/mu; + + /** The clone's common git directory for the checkout `start` sits in, absolute, as + * `git rev-parse --git-common-dir` answers it — `null` outside any checkout. Read from + * files rather than by spawning git, like `repositoryRootAbove`: a `.git` directory is its + * own common dir; a `.git` file points at a worktree's git dir, whose `commondir` file + * points at the shared one. A pointer whose `commondir` cannot be read answers the git dir + * itself, which is what git does for a submodule. */ + export function gitCommonDirAbove(start: string): string | null { + const root = repositoryRootAbove(start); + const dotGit = join(root, GIT_DIR_NAME); + let isDirectory: boolean; + try { + isDirectory = statSync(dotGit).isDirectory(); + } catch { + return null; + } + if (isDirectory) return dotGit; + const gitDir = readGitDirPointer(root, dotGit); + if (gitDir === null) return null; + try { + return resolve(gitDir, readFileSync(join(gitDir, "commondir"), "utf8").trim()); + } catch { + return gitDir; + } + } + + function readGitDirPointer(root: string, dotGitFile: string): string | null { + try { + const match = GITDIR_POINTER.exec(readFileSync(dotGitFile, "utf8")); + return match?.[1] ? resolve(root, match[1]) : null; + } catch { + return null; + } + } + + /** Every working tree a clone still registers and that still exists on disk: the main one + * first (only a non-bare clone has one, its common dir being `/.git`), then each + * linked worktree, by its entry name. A worktree deleted without `git worktree prune` + * keeps its entry but names a directory that is gone, so it is skipped. */ + export function worktreeRootsOf(commonDir: string): readonly string[] { + const roots: string[] = []; + if (basename(commonDir) === GIT_DIR_NAME) roots.push(dirname(commonDir)); + const worktreesDir = join(commonDir, "worktrees"); + let entries: string[]; + try { + entries = readdirSync(worktreesDir); + } catch { + return roots; + } + for (const entry of entries.sort()) { + try { + const pointer = readFileSync(join(worktreesDir, entry, "gitdir"), "utf8").trim(); + const root = dirname(resolve(worktreesDir, entry, pointer)); + if (existsSync(root)) roots.push(root); + } catch { + // A half-pruned entry names nothing. + } + } + return roots; + } + ``` + +3. Run the unit test: green. + +### `2)` Move `resolvedRunsDir` and add `legacyRunsDirs` + +> One resolver for the primary directory, one for the read-only legacy ones. + +1. In `cli/tests/kernel/paths.unit.test.ts` add `describe("resolvedRunsDir()")` and `describe("legacyRunsDirs()")` with real temp git repos (same setup as task 1; `afterEach` deletes `process.env.AIDD_RUNS_DIR`): + - `resolvedRunsDir(r)` → `join(r/.git, "aidd", "runs")` (compare with `samePath` after `realpathSync.native` of `r/.git`, then join); + - `resolvedRunsDir(w)` for the linked worktree → the same value; + - `resolvedRunsDir(outside)` for a temp dir with no `.git` → `join(outside, "aidd_docs", "runs")`; + - with `process.env.AIDD_RUNS_DIR = "/custom/runs"` → `"/custom/runs"`, and `legacyRunsDirs(r)` → `[]`; + - `legacyRunsDirs(r)` with `r/aidd_docs/runs` and `w/aidd_docs/runs` both created → two entries, `r`'s first; from `w` → two entries, `w`'s first; + - `legacyRunsDirs(r)` with only `w/aidd_docs/runs` created → only `w`'s; + - `legacyRunsDirs(outside)` → `[]`. + Run: fails. +2. In `cli/src/kernel/paths.ts`: + - add imports: `existsSync` from `node:fs`; `gitCommonDirAbove`, `worktreeRootsOf` from `./reading/git-common-dir.js`; + - add, below `RUNS_ENTRY`: + + ```ts + /** Where the run journal lives under a clone's common git directory. Spelled once here and + * once in the hook's `repo.cjs`, which cannot import it; a parity test holds the two. */ + const RUNS_UNDER_GIT_DIR = ["aidd", "runs"] as const; + ``` + + - replace `resolvedRunsDir` and its doc comment with: + + ```ts + /** + * Where the run journal is written and read first — under the clone's common git directory, + * which every worktree of one clone shares and `git worktree remove` never deletes. Outside + * any checkout, `/aidd_docs/runs` as before. The one resolver, so two readers + * cannot disagree from a subdirectory or a worktree. `AIDD_RUNS_DIR` overrides it outright, + * matching the hook. + */ + export function resolvedRunsDir(projectRoot: string): string { + if (process.env.AIDD_RUNS_DIR) return process.env.AIDD_RUNS_DIR; + const commonDir = gitCommonDirAbove(projectRoot); + return commonDir + ? join(commonDir, ...RUNS_UNDER_GIT_DIR) + : join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); + } + + /** + * Where the run journal lived before it moved under the common git directory: each live + * checkout's own `aidd_docs/runs`, current checkout first, then the main one, then every + * linked worktree — existing directories only, never written. Read so sessions journalled + * before the move stay counted. Empty under `AIDD_RUNS_DIR`, which replaces every location, + * and outside a checkout, where `resolvedRunsDir` already is that directory. + */ + export function legacyRunsDirs(projectRoot: string): readonly string[] { + if (process.env.AIDD_RUNS_DIR) return []; + const commonDir = gitCommonDirAbove(projectRoot); + if (commonDir === null) return []; + const dirs: string[] = []; + for (const root of [repositoryRootAbove(projectRoot), ...worktreeRootsOf(commonDir)]) { + const dir = join(root, DOCS_DIR, RUNS_SUBDIR); + if (!existsSync(dir) || dirs.some((known) => samePath(resolve(known), resolve(dir)))) continue; + dirs.push(dir); + } + return dirs; + } + ``` + + - add `resolve` to the existing `node:path` import if it is not there. `samePath` is defined lower in the same file; a function declaration is hoisted, so no reordering is needed. +3. Run `cd cli && pnpm test`. Expected: the new tests pass. Existing tests that seeded `aidd_docs/runs` inside a temp dir carrying a `.git` now find the primary directory empty; they are fixed in phase 3, not here. If any test outside `run-journal-*`, `telemetry-*` e2e and `forget-telemetry-*` fails, stop and report. + +### `3)` Hold the hook and the CLI to one location + +> A parity test across the language boundary, like the trailer one. + +1. In `cli/tests/helpers/telemetry-journal-hook.ts`, add to `JournalRepoModule`: + + ```ts + /** `null` when telemetry is off or the directory is outside a checkout. */ + resolveRunsDir(cwd: string): { readonly dir: string; readonly repoRoot: string } | null; + ``` + +2. Create `cli/tests/integration/run-journal-location-agrees.integration.test.ts`: + - `describe("the hook and the CLI resolve the run journal to one directory")`; + - `beforeEach`: delete `process.env.AIDD_RUNS_DIR` and `process.env.AIDD_TELEMETRY`; build temp fixtures as in task 1 (plain repo `r` with a commit, linked worktree `w`, bare clone `b.git` with worktree `bw`), and write `.aidd/config.json` = `{"telemetry":{"enabled":true}}` into `r`, `w` and `bw`; + - `it.each` over `["the main checkout", r]`, `["a subdirectory", join(r, "sub")]` (create it), `["a linked worktree", w]`, `["a bare clone's worktree", bw]`: assert `journalRepo.resolveRunsDir(cwd)` is not null and `samePath(canon(hookDir), canon(resolvedRunsDir(cwd)))`, where `canon(p)` = `join(realpathSync.native(dirname(dirname(p))), "aidd", "runs")` (the `runs` dir may not exist yet; its grandparent, the common dir, does); + - `it("agrees that AIDD_RUNS_DIR wins")`: set `process.env.AIDD_RUNS_DIR` to a temp dir, assert both answer exactly that string; + - `afterEach`: delete env vars, `rm` the temp root. +3. Run `cd cli && pnpm vitest run tests/integration/run-journal-location-agrees.integration.test.ts`: green. Mutation check: temporarily change `RUNS_UNDER_GIT_DIR` in `paths.ts` to `["aidd", "run"]`, see this test go red, revert. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | From a main checkout, a subdirectory, a linked worktree and a bare clone's worktree, `gitCommonDirAbove` names the directory `git rev-parse --git-common-dir` names; outside any checkout it answers `null`. | +| 1 | `worktreeRootsOf` lists the main root first then live linked worktrees, skips a deleted one, and lists no main root for a bare clone. | +| 2 | `resolvedRunsDir` answers `/aidd/runs` inside any checkout, `/aidd_docs/runs` outside, and `AIDD_RUNS_DIR` when set. | +| 2 | `legacyRunsDirs` lists every existing `aidd_docs/runs` of the clone's live checkouts, current first, without duplicates, and is empty under `AIDD_RUNS_DIR` or outside a checkout. | +| 3 | The hook and the CLI resolve the same directory from each of the four checkout shapes, and a one-segment change on the CLI side turns the parity test red. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-3.md new file mode 100644 index 000000000..63f5851c2 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-3.md @@ -0,0 +1,237 @@ +--- +status: done +--- + +# Instruction: The journal reader reads the new directory and every legacy one + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/contexts/telemetry/ + │ ├── domain/ports/run-journal-reader.ts ✏️ RunJournalStore gains legacyRunsDirs and listRunFilesIn + │ └── infrastructure/run-journal-reader-adapter.ts ✏️ reads primary then legacy; primary wins per session + └── tests/ + ├── helpers/ports/in-memory-run-journal-reader.ts ✏️ double implements the two new members + └── contexts/telemetry/infrastructure/ + └── run-journal-reader-adapter.integration.test.ts ✏️ new cases for common dir, legacy, precedence, override +``` + +`read-local-cost-use-case.ts`, `report-cost-use-case.ts` and `diagnose-telemetry-use-case.ts` call only `read`, `list` and `listForeignSchemas`; they get the legacy journals through the adapter with no change of their own. Do not edit them in this phase. + +## User Journey + +```mermaid +flowchart TD + A[report / read / check from any checkout] --> B[RunJournalReaderAdapter] + B --> C[runsDir: common-dir/aidd/runs] + B --> D[legacyRunsDirs: each live checkout's aidd_docs/runs] + C --> E{same session already seen?} + D --> E + E -->|no| F[journal returned] + E -->|yes| G[skipped: the primary copy wins] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + temp project with a .git directory, a run file under .git/aidd/runs and one under a second worktree's aidd_docs/runs => primary and legacy seeded: 5: system + section Happy path + list() => both sessions returned: 5: system + read(id of the legacy session) => its journal: 5: system + section Edge case - same session in both + one vendor id in primary and legacy with different skills => read and list => only the primary journal: 1: system + section Edge case - AIDD_RUNS_DIR + AIDD_RUNS_DIR set and a legacy file present => list => only the AIDD_RUNS_DIR journal: 1: system + section Edge case - foreign schema in legacy + a legacy file stating schema_version 99 => listForeignSchemas => [99]: 1: system + section Teardown + remove temp dirs, unset AIDD_RUNS_DIR => baseline: 5: system +``` + +## Tasks to do + +### `1)` Widen the store port + +> Say what a caller needs to name and remove every location. + +1. In `cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts`, in `RunJournalStore`, below `runsDir`, add: + + ```ts + /** Where the run journal lived before it moved under the common git directory — each live + * checkout's own `aidd_docs/runs`, read but never written. Empty under `AIDD_RUNS_DIR`. + * Exposed for the same reason as `runsDir`: a caller removing files names these exact + * directories rather than re-deriving them. */ + readonly legacyRunsDirs: readonly string[]; + /** Every run file's name directly in `dir`, one of `runsDir` or `legacyRunsDirs` — never + * opened, never parsed, sorted. Never throws; an unreadable directory answers an empty list. */ + listRunFilesIn(dir: string): Promise; + ``` + +2. In the doc comment of `listRunFiles()` (in `RunJournalReader`), add one sentence at the end: `Lists \`runsDir\` alone; \`listRunFilesIn\` names a legacy directory's files.` +3. In `RunJournalReader.list()` doc comment, add: `Reads \`runsDir\` first, then every legacy directory; a session already read from an earlier directory is skipped, so the primary copy wins.` + +### `2)` Test the adapter first + +> Describe the new reads, watch them fail. + +In `cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts`, inside the existing `describe("RunJournalReaderAdapter")`, add the cases below. Reuse the file's `RUN_ID`, `SESSION_ID`, `runFileLines`, `projectRoot`. Add a second run id constant `const OTHER_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAW";` and a second session `const LEGACY_SESSION_ID = "33333333-3333-4333-8333-333333333333";` at the top. The fake second worktree is built from files, no git needed: + +```ts +// A second checkout registered with this clone: `/.git/worktrees/wt/gitdir` +// names `/.git`, the way git records a linked worktree. +async function registerWorktree(other: string): Promise { + await mkdir(join(projectRoot, ".git", "worktrees", "wt"), { recursive: true }); + await mkdir(other, { recursive: true }); + await writeFile(join(other, ".git"), `gitdir: ${join(projectRoot, ".git", "worktrees", "wt")}\n`); + await writeFile(join(projectRoot, ".git", "worktrees", "wt", "gitdir"), `${join(other, ".git")}\n`); + await writeFile(join(projectRoot, ".git", "worktrees", "wt", "commondir"), "../..\n"); +} +``` + +Cases (exact titles): + +1. `reads a journal written under the common git directory`: `mkdir(join(projectRoot, ".git", "aidd", "runs"), { recursive: true })`, write `${RUN_ID}__${SESSION_ID}.jsonl` there with a `step_start` skill `from-common-dir`; `new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)` returns that boundary. +2. `lists a journal still sitting in another live worktree's aidd_docs/runs`: `mkdir(join(projectRoot, ".git"))`, `const other = await mkdtemp(join(tmpdir(), "aidd-run-journal-other-"))`, `registerWorktree(other)`, write `${OTHER_RUN_ID}__${LEGACY_SESSION_ID}.jsonl` into `join(other, "aidd_docs", "runs")` with a `session_start` line (`type`, `at`, `run_id: OTHER_RUN_ID`, `tool: "claude-code"`, `vendor_id: LEGACY_SESSION_ID`, `schema_version: 2`); `list()` returns one journal whose `session.vendor_id` is `LEGACY_SESSION_ID`; `read(LEGACY_SESSION_ID)` is not null. Remove `other` at the end. +3. `prefers the common git directory's copy when one session has a file in both`: `.git` dir, write the same `${RUN_ID}__${SESSION_ID}.jsonl` name into `.git/aidd/runs` (skill `primary`) and into `projectRoot/aidd_docs/runs` (skill `legacy`); `read(SESSION_ID)` boundaries show skill `primary`; `list()` has length 1 with skill `primary`. +4. `reads nothing but AIDD_RUNS_DIR when it is set, even with legacy journals present`: `.git` dir, a file in `projectRoot/aidd_docs/runs` with vendor `SESSION_ID`, `process.env.AIDD_RUNS_DIR` = temp dir holding `${OTHER_RUN_ID}__${LEGACY_SESSION_ID}.jsonl`; `list()` returns exactly one journal (the override's, by `session.vendor_id` or by boundary skill); `adapter.legacyRunsDirs` equals `[]`. +5. `reports a foreign schema found in a legacy directory`: `.git` dir, a legacy file in `projectRoot/aidd_docs/runs` whose first line is a `session_start` with `schema_version: 99`; `listForeignSchemas()` equals `[99]`. +6. `names a legacy directory's run files by name`: `.git` dir, two files in `projectRoot/aidd_docs/runs` (`${RUN_ID}__a.jsonl`, `${OTHER_RUN_ID}__b.jsonl`) plus `notes.txt`; `adapter.legacyRunsDirs` has one entry equal (by `samePath`) to `join(projectRoot, "aidd_docs", "runs")`; `listRunFilesIn(thatEntry)` equals the two `.jsonl` names sorted; `listRunFiles()` equals `[]`. + +Run `cd cli && pnpm vitest run tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts`: the new cases fail (no `legacyRunsDirs`, no `listRunFilesIn`, legacy not read). + +### `3)` Read primary then legacy in the adapter + +> One ordered list of directories; the first file for a session wins. + +In `cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts`: + +1. Imports: add `resolve` to the `node:path` import; change the `paths.js` import to `import { legacyRunsDirs, resolvedRunsDir, samePath } from "../../../kernel/paths.js";`. +2. Replace `matchesVendorId` with two functions, keeping the comment above it: + + ```ts + // Mirrors record.cjs's parseRunFileName: split on the fixed ULID length, never on "__", + // since a sanitized vendor id can itself contain that substring. `null` for any name that + // is not a run file, `_unrecognised.jsonl` included. + function vendorSegmentOf(entry: string): string | null { + if (!entry.endsWith(RUN_FILE_EXTENSION)) return null; + const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; + if (entry.length <= minLength) return null; + if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return null; + return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length); + } + + function matchesVendorId(entry: string, wantedSegment: string): boolean { + return vendorSegmentOf(entry) === wantedSegment; + } + ``` + +3. In the class doc comment, append: `Reads \`runsDir\` first, then every legacy directory, both resolved once in the constructor; the first file found for a session wins.` +4. Class body changes: + - add field `readonly legacyRunsDirs: readonly string[];`; + - constructor: after `this.runsDir = resolvedRunsDir(projectRoot);` add `this.legacyRunsDirs = legacyRunsDirs(projectRoot).filter((dir) => !samePath(resolve(dir), resolve(this.runsDir)));`; + - add private getter: + + ```ts + private get readDirs(): readonly string[] { + return [this.runsDir, ...this.legacyRunsDirs]; + } + ``` + + - `read(sessionId)`: + + ```ts + async read(sessionId: string): Promise { + for (const dir of this.readDirs) { + const filePath = await this.findRunFile(dir, sessionId); + if (filePath) return this.readJournal(filePath); + } + return null; + } + ``` + + - `list()`: + + ```ts + async list(): Promise { + const journals: RunJournal[] = []; + const seen = new Set(); + for (const dir of this.readDirs) { + for (const entry of await this.listRunFilesIn(dir)) { + const segment = vendorSegmentOf(entry); + if (segment !== null) { + if (seen.has(segment)) continue; + seen.add(segment); + } + const journal = await this.readJournal(join(dir, entry)); + if (journal) journals.push(journal); + } + } + return journals; + } + ``` + + - `listForeignSchemas()`: loop `for (const dir of this.readDirs) for (const fileName of await this.listRunFilesIn(dir))`, collecting from `join(dir, fileName)`; body otherwise unchanged. + - `listRunFiles()` becomes `return this.listRunFilesIn(this.runsDir);` + - add: + + ```ts + async listRunFilesIn(dir: string): Promise { + try { + const entries = await readdir(dir); + return entries.filter((entry) => entry.endsWith(RUN_FILE_EXTENSION)).sort(); + } catch { + return []; + } + } + ``` + + `deleteRunFile` and `findRunFile` stay as they are. + + Note: `list()` used to read every `.jsonl` (including `_unrecognised.jsonl`) and drop what does not parse; it still does, since `listRunFilesIn` keeps every `.jsonl` and only names with a vendor segment take part in the seen-set. + +### `4)` Update the in-memory double + +> Tests that use the double must compile and behave as before. + +In `cli/tests/helpers/ports/in-memory-run-journal-reader.ts`: + +1. In `InMemoryRunJournalReader` add: + + ```ts + /** Settable, like `runFileNames`: what a legacy directory holds, by directory. */ + legacyRunsDirs: readonly string[] = []; + readonly legacyRunFileNames = new Map(); + + async listRunFilesIn(dir: string): Promise { + return dir === this.runsDir ? this.runFileNames : (this.legacyRunFileNames.get(dir) ?? []); + } + ``` + +2. In `deleteRunFile`, after the `undeletable` check and `deletedFromDirs.push(dir)`: if `dir === this.runsDir` filter `runFileNames` as today; otherwise replace the map entry for `dir` with its list minus `fileName`. Always `deletedFiles.push(fileName)`. +3. In `NULL_RUN_JOURNAL_READER` add `legacyRunsDirs: []` and `listRunFilesIn: async () => []`. + +### `5)` Run + +1. `cd cli && pnpm vitest run tests/contexts/telemetry`: green except `forget-telemetry-use-case.unit.test.ts` if it now fails (handled in phase 4). +2. `cd cli && pnpm test`: the only allowed failures are in `tests/e2e/telemetry-forget.e2e.test.ts` and `tests/e2e/telemetry-check.e2e.test.ts` (phase 4). Anything else failing: stop and report. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `RunJournalStore` exposes `legacyRunsDirs` and `listRunFilesIn`, and the typecheck passes with the adapter and both doubles implementing them. | +| 2 | Before task 3, the six new cases fail. | +| 3 | A journal under `/aidd/runs` is read; a journal in another live worktree's `aidd_docs/runs` is listed and readable by its session id. | +| 3 | When one session has a file in both places, only the common-dir copy is returned by `read` and by `list`. | +| 3 | With `AIDD_RUNS_DIR` set, no legacy directory is read and `legacyRunsDirs` is empty. | +| 3 | A foreign schema in a legacy file is reported by `listForeignSchemas`. | +| 5 | The whole CLI suite passes apart from the forget and check e2e files named in task 5. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-4.md new file mode 100644 index 000000000..97a49f916 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-4.md @@ -0,0 +1,189 @@ +--- +status: done +--- + +# Instruction: Forget removes every location, check names the real one + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/ + │ ├── contexts/telemetry/ + │ │ ├── domain/telemetry-removal.ts ✏️ preview gains legacyJournals; emptiness counts them + │ │ └── application/ + │ │ ├── forget-telemetry-use-case.ts ✏️ previews and removes legacy journals too + │ │ └── diagnose-telemetry-use-case.ts ✏️ runsDirLabel is the reader's real runsDir + │ └── presentation/display/telemetry-forget-display.ts ✏️ one preview line per legacy journal + └── tests/ + ├── contexts/telemetry/domain/telemetry-removal.unit.test.ts ✏️ legacy-only preview is not empty + ├── contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts ✏️ legacy preview and removal + ├── contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts ✏️ label is the double's runsDir + └── presentation/display/telemetry-forget-display.unit.test.ts ✏️ legacy line printed +``` + +The `history` reading (`listTrackedFiles` / `hasHistoryFor` on `RUNS_ENTRY`) stays exactly as it is: legacy `aidd_docs/runs/` files may still be committed somewhere, and that is what it reports. + +## User Journey + +```mermaid +flowchart TD + A[aidd telemetry forget] --> B[preview: primary journal + every non-empty legacy journal + sink + identity + history] + B --> C{--yes?} + C -->|no| D[Nothing removed] + C -->|yes| E[remove exactly the previewed files in every listed directory] + F[aidd telemetry check, no run file] --> G[claim names common-dir/aidd/runs, not aidd_docs/runs] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + in-memory store with one primary file and one legacy dir holding two files => preview inputs ready: 5: system + section Happy path + preview => legacyJournals lists the legacy dir with its two names: 5: system + remove(preview) => deletes in both dirs; outcome removed = 3: 5: system + forget e2e on a git project with files in aidd_docs/runs => they are removed: 5: cli + section Edge case - empty legacy dir + legacy dir with no run file => preview => it is not listed: 1: system + section Edge case - only legacy has files + primary, sink and identity empty, a legacy file present => telemetryRemovalIsEmpty => false: 1: system + section Edge case - check label + no run file anywhere => check => the claim names the reader's runsDir: 1: system +``` + +## Tasks to do + +### `1)` Tests first + +> Pin legacy preview, removal, emptiness, display and the check label. + +1. `cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts`: add `it("is not empty when only a legacy journal holds run files")` building a preview whose `journal.runFileNames`, `sink.dayFileNames` are `[]`, `identity.present` is `false`, and `legacyJournals: [{ scope: "project", path: "/wt/aidd_docs/runs", runFileNames: ["a.jsonl"] }]`; expect `telemetryRemovalIsEmpty(preview)` to be `false`. Every existing preview literal in this file gets `legacyJournals: []`. +2. `cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts`, using `InMemoryRunJournalReader`: + - `it("previews every legacy journal that holds run files, and leaves out an empty one")`: `reader.legacyRunsDirs = ["/fake/wt/aidd_docs/runs", "/fake/empty/aidd_docs/runs"]`, `reader.legacyRunFileNames.set("/fake/wt/aidd_docs/runs", ["x.jsonl", "y.jsonl"])`; `preview.legacyJournals` equals `[{ scope: "project", path: "/fake/wt/aidd_docs/runs", runFileNames: ["x.jsonl", "y.jsonl"] }]`. + - `it("removes the previewed legacy files from their own directory and counts them with the project journal")`: same setup plus `reader.runFileNames = ["p.jsonl"]`; `remove(preview)`; `result.journal.removed` is `3`, `reader.deletedFromDirs` contains `"/fake/wt/aidd_docs/runs"` twice and `reader.runsDir` once, `reader.legacyRunFileNames.get("/fake/wt/aidd_docs/runs")` is `[]`. + - `it("reports a legacy file that refuses removal by its name and still removes the rest")`: `reader.undeletable.add("x.jsonl")`; `result.journal.removed` is `1` (`y.jsonl`), `result.journal.failed` is `[{ path: "x.jsonl", reason: "cannot delete x.jsonl" }]`. +3. `cli/tests/presentation/display/telemetry-forget-display.unit.test.ts`: every preview literal gets `legacyJournals: []`; add a case where `legacyJournals` holds `{ scope: "project", path: "/wt/aidd_docs/runs", runFileNames: ["a.jsonl", "b.jsonl"] }` and assert the printed output contains exactly the line ` An earlier run journal, from before it moved under the git directory (/wt/aidd_docs/runs): 2 run file(s)`, printed after the `This project's run journal` line and before the `This machine's stored records` line. +4. `cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts`, test `names the runs directory in the claim that found no run file` (around line 933): expected detail starts with `"no run file in /fake/project/aidd_docs/runs — the hook has never been observed firing, and the "` (the double's `runsDir`). Search the file for every other `"aidd_docs/runs"` inside an expected string (`grep -n "in aidd_docs/runs" ...`) and change each to `/fake/project/aidd_docs/runs`. +5. Run these four files: they fail (type errors on `legacyJournals`, wrong label). + +### `2)` Carry legacy journals in the removal preview + +> The type says every place a removal touches. + +In `cli/src/contexts/telemetry/domain/telemetry-removal.ts`: + +1. In `TelemetryProjectJournalRemoval`, change the `path` doc comment to: `/** The run journal's own directory: \`RunJournalStore.runsDir\`, or one of its \`legacyRunsDirs\`. */` +2. In `TelemetryRemovalPreview`, below `journal`, add: + + ```ts + /** Each pre-move `aidd_docs/runs` of a live checkout of this clone that still holds run + * files, as `RunJournalStore.legacyRunsDirs` resolved them. Empty is the ordinary answer. */ + readonly legacyJournals: readonly TelemetryProjectJournalRemoval[]; + ``` + +3. In `telemetryRemovalIsEmpty`, add the condition `preview.legacyJournals.every((legacy) => legacy.runFileNames.length === 0) &&` right after the `preview.journal.runFileNames.length === 0 &&` line. + +### `3)` Preview and remove legacy journals in forget + +> Same rule as everything else in forget: remove exactly what was shown. + +In `cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts`: + +1. In `preview()`, after the `Promise.all` destructuring, add: + + ```ts + const legacyJournals = await this.legacyJournals(); + ``` + + and return `legacyJournals` in the object, right after `journal`. +2. Add the private method below `preview()`: + + ```ts + // Only a directory still holding run files is shown: an empty pre-move directory is + // nothing to remove and nothing to confirm. + private async legacyJournals(): Promise { + const found: TelemetryProjectJournalRemoval[] = []; + for (const path of this.runJournalReader.legacyRunsDirs) { + const runFileNames = await this.runJournalReader.listRunFilesIn(path); + if (runFileNames.length > 0) found.push({ scope: "project", path, runFileNames }); + } + return found; + } + ``` + +3. Replace `remove()` with: + + ```ts + async remove(preview: TelemetryRemovalPreview): Promise { + const [journal, legacy, sink, identity] = await Promise.all([ + this.removeJournal(preview.journal), + Promise.all(preview.legacyJournals.map((legacyJournal) => this.removeJournal(legacyJournal))), + this.removeSink(preview.sink), + this.removeIdentity(preview.identity), + ]); + return { journal: mergeOutcomes([journal, ...legacy]), sink, identity, history: preview.history }; + } + ``` + +4. Add at module level, below the interfaces: + + ```ts + /** One count for the project's journals, wherever each file sat: a person confirmed one + * "run journal", and the result answers in the same unit. */ + function mergeOutcomes(outcomes: readonly TelemetryRemovalOutcome[]): TelemetryRemovalOutcome { + return { + removed: outcomes.reduce((sum, outcome) => sum + outcome.removed, 0), + failed: outcomes.flatMap((outcome) => outcome.failed), + }; + } + ``` + +5. `removeJournal` is unchanged: it already deletes from `journal.path`, the directory the person was shown. + +### `4)` Print legacy journals in the forget preview + +In `cli/src/presentation/display/telemetry-forget-display.ts`, in `printTelemetryForgetPreview`, right after the `This project's run journal (...)` `output.print(...)` call, add: + +```ts +for (const legacy of preview.legacyJournals) { + output.print( + ` An earlier run journal, from before it moved under the git directory (${legacy.path}): ` + + `${legacy.runFileNames.length} run file(s)` + ); +} +``` + +`printTelemetryForgetResult` is unchanged (the merged outcome prints under `This project's run journal`). + +### `5)` Name the real directory in check + +In `cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts`: + +1. Delete `const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs";`. +2. Change the constructor parameter type `private readonly runJournalReader: RunJournalReader` to `RunJournalStore`, and the type import accordingly (`import type { RunJournal, RunJournalStore } from "../domain/ports/run-journal-reader.js";` — keep whatever else that import already names). The wiring in `src/runtime/wiring/telemetry.ts` already passes the adapter, which implements `RunJournalStore`; do not change the wiring. +3. In `gatherEvidence`, `runsDirLabel: DEFAULT_RUNS_DIR_LABEL` becomes `runsDirLabel: this.runJournalReader.runsDir`. + +### `6)` Run + +1. The four test files from task 1: green. +2. `cd cli && pnpm test`: fully green, `tests/e2e/telemetry-forget.e2e.test.ts` and `tests/e2e/telemetry-check.e2e.test.ts` included. If a forget e2e still fails, read its assertion: a project created with `gitInit` now has its seeded `aidd_docs/runs` files listed as a legacy journal and removed through it, which is the intended behaviour; fix production code, not the e2e, unless the e2e asserts the old `aidd_docs/runs` text of the check label. +3. From the repository root: `pnpm exec lefthook run pre-commit` and `pnpm exec lefthook run pre-push`: green. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 2 | A preview whose only content is a legacy journal with run files is not empty. | +| 3 | The forget preview lists each legacy directory holding run files, with their names, and omits an empty one. | +| 3 | Confirming removes the previewed files from their own legacy directory, counted together with the primary journal; a file refusing removal is reported by name and the rest are still removed. | +| 4 | The preview prints one `An earlier run journal ...` line per legacy directory, between the project journal line and the machine records line. | +| 5 | `aidd telemetry check` with no run file names the reader's real `runsDir` instead of `aidd_docs/runs`. | +| 6 | The whole CLI suite, pre-commit and pre-push pass. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-5.md new file mode 100644 index 000000000..6145d3ff5 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-5.md @@ -0,0 +1,153 @@ +--- +status: done +--- + +# Instruction: End-to-end proof on real worktrees + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/tests/e2e/ + └── telemetry-worktree-journal.e2e.test.ts ✅ the issue's QA, through the built CLI on real git worktrees +``` + +No production code changes in this phase. If a case fails, the defect is in phases 2 to 4: fix it there, re-run that phase's gate, then come back. + +## User Journey + +```mermaid +flowchart TD + A[real repo + linked worktree, telemetry on] --> B[journal A under main/.git/aidd/runs, from the worktree] + A --> C[journal B left in the worktree's aidd_docs/runs, pre-move] + B --> D[report from main: tasks of A and B] + C --> D + D --> E[report from the worktree: same tasks] + E --> F[git worktree remove --force] + F --> G[report from main: A still there, B gone] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + git init, commit, git worktree add, telemetry config, two journals, sink records for both sessions => fixture ready: 5: system + section Happy path + aidd telemetry report --json from the main checkout => by_task has alpha (cost 2) and beta (cost 3): 5: cli + aidd telemetry report --json from the linked worktree => by_task has alpha and beta: 5: cli + git worktree remove --force, then report from the main checkout => alpha still in by_task: 5: cli + section Edge case - pre-move journal of a removed worktree + the worktree holding journal B is removed => report from main => beta is no longer attributed: 1: cli + section Teardown + cleanup removes the temp dir, worktree included => baseline: 5: system +``` + +## Tasks to do + +### `1)` Write the end-to-end test + +> Prove #932's QA through the real binary. + +Create `cli/tests/e2e/telemetry-worktree-journal.e2e.test.ts`. Model it on `cli/tests/e2e/telemetry-task-midsession.e2e.test.ts`: same imports style, same `record()` helper, same `Envelope`/`TaskRow` interfaces, same `taskRowOfCost` helper, same `afterEach(cleanup)` pattern. + +1. Imports: + + ```ts + import { execFileSync } from "node:child_process"; + import { mkdir, writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { afterEach, describe, expect, it } from "vitest"; + import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; + import { createTestEnv, gitInit, runCli } from "./helpers.js"; + ``` + +2. Constants: + + ```ts + const PROJECT_ID = "acme/worktrees"; + const PERIOD = ["--from", "2026-02-01", "--to", "2026-02-28"]; + const ALPHA_TASK = "2026_02/2026_02_10_alpha"; + const BETA_TASK = "2026_02/2026_02_10_beta"; + // A: journalled where the hook now writes. B: left in a worktree by a pre-move hook. + const RUN_A = "01ARZ3NDEKTSV4RRFFQ69G5FA1"; + const VENDOR_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const RUN_B = "01ARZ3NDEKTSV4RRFFQ69G5FB1"; + const VENDOR_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const WORKTREE_NAME = "agent-wt"; + ``` + +3. Helpers: + + ```ts + function git(cwd: string, args: readonly string[]): void { + execFileSync("git", ["-c", "user.email=t@example.com", "-c", "user.name=Test", ...args], { + cwd, + env: environmentWithoutGitVariables(process.env), + }); + } + + function journal(runId: string, vendorId: string, task: string, startAt: string): string { + const lines = [ + { + type: "session_start", + at: startAt, + schema_version: 2, + run_id: runId, + tool: "codex", + vendor_id: vendorId, + project_id: PROJECT_ID, + worktree_id: WORKTREE_NAME, + worktree_repo_id: "project", + }, + { type: "task_declared", at: startAt.replace(":00:00Z", ":10:00Z"), path: `aidd_docs/tasks/${task}/spec.md` }, + ]; + return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`; + } + ``` + + Copy `record(overrides)` verbatim from `telemetry-task-midsession.e2e.test.ts` (it uses `PROJECT_ID`, defined above). + +4. Sink records, one per session, each after its declaration: + + ```ts + const RECORDS = [ + record({ vendor_id: VENDOR_A, turn_id: "a", event_timestamp: "2026-02-10T09:20:00Z", cost_usd: 2 }), + record({ vendor_id: VENDOR_B, turn_id: "b", event_timestamp: "2026-02-10T11:20:00Z", cost_usd: 3 }), + ]; + ``` + +5. `seed()` returns `{ projectDir, worktreeDir, fakeHome }`: + 1. `const env = await createTestEnv("telemetry-worktree-journal"); cleanup = env.cleanup;` + 2. `await gitInit(env.projectDir); git(env.projectDir, ["commit", "-q", "--allow-empty", "-m", "seed"]);` + 3. Ignore the project's own untracked files the way a real project does, so `git worktree add` and later commands see a clean tree: `await writeFile(join(env.projectDir, ".git", "info", "exclude"), ".aidd/\naidd_docs/\n");` + 4. `const worktreeDir = join(env.tempDir, WORKTREE_NAME); git(env.projectDir, ["worktree", "add", "-q", "-b", WORKTREE_NAME, worktreeDir]);` + 5. Telemetry on in both checkouts: write `{"telemetry":{"enabled":true}}` to `.aidd/config.json` in `env.projectDir` and in `worktreeDir` (create `.aidd` first). + 6. Journal A where the hook now writes: `const commonRuns = join(env.projectDir, ".git", "aidd", "runs"); await mkdir(commonRuns, { recursive: true }); await writeFile(join(commonRuns, \`${RUN_A}__${VENDOR_A}.jsonl\`), journal(RUN_A, VENDOR_A, ALPHA_TASK, "2026-02-10T09:00:00Z"));` + 7. Journal B where a pre-move hook wrote it: `const legacyRuns = join(worktreeDir, "aidd_docs", "runs"); await mkdir(legacyRuns, { recursive: true }); await writeFile(join(legacyRuns, \`${RUN_B}__${VENDOR_B}.jsonl\`), journal(RUN_B, VENDOR_B, BETA_TASK, "2026-02-10T11:00:00Z"));` + 8. Sink: `const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); await mkdir(sinkDir, { recursive: true }); await writeFile(join(sinkDir, "2026-02-28.jsonl"), \`${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n\`);` + 9. `return { projectDir: env.projectDir, worktreeDir, fakeHome: env.fakeHome };` + +6. Cases, inside `describe("aidd telemetry report — sessions from every worktree of one clone, before and after removal")`: + 1. `it("counts a session journalled under the common git directory and one still in a live worktree, from the main checkout")`: `runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome)`; exit code `0`; `taskRowOfCost(envelope, 2)?.task` is `ALPHA_TASK`; `taskRowOfCost(envelope, 3)?.task` is `BETA_TASK`. + 2. `it("counts the same sessions when the report runs from the linked worktree")`: same assertions with `worktreeDir` as cwd. + 3. `it("still counts the worktree's session once the worktree is removed")`: `seed()`, then `git(projectDir, ["worktree", "remove", "--force", worktreeDir])` (`--force` because journal B is an untracked file in it); report from `projectDir`; `taskRowOfCost(envelope, 2)?.task` is `ALPHA_TASK`; `taskRowOfCost(envelope, 3)?.task` is **not** `BETA_TASK` (a pre-move journal goes with its worktree, the accepted edge in `plan.md`). + 4. `it("never writes a session's journal into the worktree's own aidd_docs/runs")` is NOT added here: the hook's own suite (phase 1) proves it. + +7. Run `cd cli && pnpm vitest run tests/e2e/telemetry-worktree-journal.e2e.test.ts` (the e2e project builds or locates the binary the way every other e2e file does; if it reports a missing `dist/cli.js`, run `cd cli && pnpm build` first). All three green. +8. Mutation check: in `cli/src/kernel/paths.ts`, temporarily make `legacyRunsDirs` return `[]`; case 1 and 2 must go red on `BETA_TASK`; revert. Then temporarily make `resolvedRunsDir` return `join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR)` unconditionally; case 3 must go red on `ALPHA_TASK`; revert. +9. `cd cli && pnpm test`, then from the root `pnpm exec lefthook run pre-commit` and `pnpm exec lefthook run pre-push`: green. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | From the main checkout, the report attributes a session journalled under the common git directory and a session still journalled in a live worktree's `aidd_docs/runs`, each to its task. | +| 1 | From the linked worktree, the report shows the same two attributions. | +| 1 | After `git worktree remove`, the session journalled under the common git directory is still attributed to its task. | +| 1 | Disabling legacy reading, or the common-dir location, turns the matching case red. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-6.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-6.md new file mode 100644 index 000000000..b36dcf6d6 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/phase-6.md @@ -0,0 +1,136 @@ +--- +status: done +--- + +# Instruction: Decision record and docs + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── CLAUDE.md ✏️ only if the context gate asks for the new decision's bullet +├── aidd_docs/ +│ ├── memory/internal/decisions/one-run-journal-per-clone.md ✅ the decision #932 asks to write down +│ ├── memory/project-brief.md ✏️ Run journal row: new location +│ └── runs/README.md ✏️ where records land; worktree paragraph +├── cli/ +│ ├── README.md ✏️ the aidd_docs/runs/ row +│ └── aidd_docs/memory/telemetry.md ✏️ "Lives at" bullet +├── docs/FAQ.md ✏️ stored-data table row +└── plugins/aidd-telemetry/ + ├── README.md ✏️ "Stored data" paragraph + └── skills/00-init/actions/ + ├── 02-enable.md ✏️ steps 1 and 3 + └── 03-verify.md ✏️ step 1 command +``` + +Not changed, on purpose: `docs/ARCHITECTURE.md` and `aidd_docs/product/metrics-contract.md` only link to `aidd_docs/runs/README.md`, which stays the journal contract's home; `cli/.claude/skills/framework/references/post-install-pipeline.md` is still true (install still git-ignores `aidd_docs/runs/`). + +## User Journey + +```mermaid +flowchart TD + A[reader opens the journal contract or a skill] --> B[reads: records land under the clone's common git directory] + B --> C[reads: older aidd_docs/runs journals of live checkouts are still read] + B --> D[finds the decision record and its reasons] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Happy path + grep the edited docs for a sentence saying records land in aidd_docs/runs => none left: 5: system + pnpm exec lefthook run pre-commit => green, doc links, referenced paths and duplication checks included: 5: system + section Edge case - duplicated sentence + the same sentence written in two documents => check-doc-duplication fails => reword one of them: 1: system +``` + +## Tasks to do + +### `1)` Write the decision record + +> #932's proposed solution, point 1: decide and write the decision down. + +Create `aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md` with exactly this content (fill nothing else in): + +```markdown +# One run journal per clone, under its common git directory + +- Date: 2026-10-09 +- Status: Accepted — closes the open choice in #932, amends the per-worktree default kept after #693 +- Decided by: proposed by Vincent Menard, for a maintainer to confirm or overturn + +## Context + +The hook wrote each session's journal at the root of the checkout it ran in, `/aidd_docs/runs/`. The CLI read only the journal of the checkout it ran in, and the sink is filled only through that journal. `aidd telemetry on` git-ignores `aidd_docs/runs/`, and `git worktree remove` deletes a worktree whose only extra content is ignored without needing `--force`. A session an agent ran in its own worktree was therefore missing from every report outside that worktree, and lost for good once the worktree was removed. Agent runners give each agent its own worktree, so this was the common case. + +The per-worktree default had two sound reasons: nothing should write into another branch's working tree, and a bare clone has no main working tree to write into. + +## Decision + +1. The run journal of a clone lives in one directory, `/aidd/runs/`, shared by every worktree of that clone. It is outside every working tree, so it is never committed, dirties no branch, and survives `git worktree remove`. A bare clone has a common dir too. +2. `AIDD_RUNS_DIR` still replaces that location entirely. +3. Journals already written to a live checkout's `aidd_docs/runs/` are still read, never written. The copy under the common git directory wins when one session has both. +4. `worktree_id` stays on the `session_start` line. The report gains no worktree axis: two worktrees that worked on one task add up. The 2026-08-31 decision requires an argument for a new axis, and none was made. + +## Alternatives + +- **Keep one journal per worktree and gather them at report time.** Solves the report, not the removal: the journal still goes with the worktree. +- **Write into the main working tree.** Impossible for a bare clone, and dirties a checkout on another branch. +- **Copy each worktree's journal into the main one.** Two copies of one session diverge, and the copy only happens if someone reports before the removal. + +## Consequences + +- A worktree removed before this change, never read before, stays lost. +- The two clones of one remote keep two journals; the sink and the project axis already pool them. +- The backlog axis still resolves task folders in the checkout running the report, and catch-up still follows that checkout's switch. Both are outside this decision. +- Hook and CLI spell the location separately (`repo.cjs`, `cli/src/kernel/paths.ts`); a parity test holds them together. +``` + +### `2)` Update the journal contract + +`aidd_docs/runs/README.md`: + +1. Replace the whole first paragraph after `# aidd_docs/runs` with: + + ```markdown + The run journal's contract. Records no longer land in this directory: once AIDD telemetry is turned on, the hook writes them under the clone's common git directory, `/aidd/runs/` (for a plain checkout, `.git/aidd/runs/`), shared by every worktree of the clone and outside every working tree, so `git worktree remove` never takes them and no commit can carry them. `AIDD_RUNS_DIR` replaces that location entirely. The single authoritative switch is still `.aidd/config.json`'s `telemetry.enabled` in the checkout a session runs in, read by `plugins/aidd-telemetry/hooks/journal.cjs` at the point of every write, never cached across a session; the directory is created on demand. Journals an earlier version wrote into a live checkout's `aidd_docs/runs/` are still read by the CLI, never written; they stay git-ignored (see `.gitignore`). Why: `aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md`. + ``` + + (The old paragraph named `hooks/journal.js`; the file is `journal.cjs`.) +2. In the `worktree_id` paragraph (the one starting ``` `worktree_id` is git's own name ```), append one sentence: `Every worktree of a clone writes into the same directory, so these two keys are what keeps their sessions apart.` + +### `3)` Update the other docs, one line each + +Write each replacement exactly; do not copy the README paragraph into them (the duplication check fails on a sentence written twice). + +1. `aidd_docs/memory/project-brief.md`, the `Run journal` table row: `| Run journal | what a session did, appended by a hook under the clone's common git directory (`/aidd/runs/`) |` +2. `cli/README.md` line ~183, replace the row with: `| `/aidd/runs/` | The run journal, shared by every worktree of the clone; older journals in a checkout's `aidd_docs/runs/` are still read |` +3. `cli/aidd_docs/memory/telemetry.md`, the bullet starting `- Lives at the git root above the project:` becomes: `- Lives under the clone's common git directory: `kernel/paths.ts`'s `resolvedRunsDir` finds it from files via `kernel/reading/git-common-dir.ts`, and `legacyRunsDirs` adds each live checkout's pre-move `aidd_docs/runs` for reading only. `AIDD_RUNS_DIR` overrides both, read alike by `hooks/lib/repo.cjs` and the CLI.` +4. `docs/FAQ.md`, stored-data table, the first row's `Where` cell: `` `.git/aidd/runs/` in your clone, outside every working tree `` (keep the `What` cell). +5. `plugins/aidd-telemetry/README.md`, "Stored data": the sentence `Hooks append one line per observation to git-ignored `aidd_docs/runs/__.jsonl`, never rewriting it ...` becomes `Hooks append one line per observation to `/aidd/runs/__.jsonl`, one directory per clone that every worktree shares and no commit can reach, never rewriting it ...` (keep the rest of the sentence as is). +6. `plugins/aidd-telemetry/skills/00-init/actions/02-enable.md`: + - step 1: `It writes into `aidd_docs/runs/` which session served which task` becomes `It writes, under this clone's git directory and outside every working tree, which session served which task`; + - step 3: the sentence `The same run also adds `aidd_docs/runs/` to `.gitignore` — the journal belongs to this repository and is never offered to a commit.` becomes `The same run also adds `aidd_docs/runs/` to `.gitignore`, which keeps journals an earlier version wrote there out of a commit; new ones never land in the working tree.` +7. `plugins/aidd-telemetry/skills/00-init/actions/03-verify.md`, step 1: `Run `ls aidd_docs/runs/*.jsonl`.` becomes `Run `ls "$(git rev-parse --git-common-dir)/aidd/runs/"*.jsonl`.` + +### `4)` Check and gate + +1. Run `grep -rn "aidd_docs/runs" aidd_docs/memory aidd_docs/runs/README.md docs plugins/aidd-telemetry/README.md plugins/aidd-telemetry/skills cli/README.md cli/aidd_docs`. Read every hit: each must describe either the legacy directory (still read, git-ignored), the journal contract's own file name `aidd_docs/runs/README.md`, or the `.gitignore` entry. A hit saying a session writes or lands records there is a miss: fix it with the same wording as task 3. +2. `pnpm exec lefthook run pre-commit`. If `context-reference-form` or `context-imports` reports the new decision file missing from `CLAUDE.md`, add the line `- aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md` right after the two existing `aidd_docs/memory/internal/decisions/...` bullets in `CLAUDE.md`, and run it again. If `check-doc-duplication` flags a sentence, reword the newer occurrence. Green before stopping. +3. Run the wrapped scripts suite once more: `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` (some tests read skill and README text). + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | The decision record exists, dated 2026-10-09, stating the location, the override, legacy reading, the absence of a worktree axis, and the alternatives refused. | +| 2 | The journal contract says where records land now, that legacy journals are still read, and links the decision. | +| 3 | No edited document still says a session writes into `aidd_docs/runs/`; the verify step lists run files from the common git directory. | +| 4 | The pre-commit hook and the scripts suite pass. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/plan.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/plan.md new file mode 100644 index 000000000..006981d50 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/plan.md @@ -0,0 +1,92 @@ +--- +objective: "A session run in any worktree of a clone is journalled under the clone's common git directory, survives `git worktree remove`, and is counted by a report run from any checkout of that clone." +status: implemented +--- + +# Plan: A worktree session outlives the worktree's removal + +## Overview + +| Field | Value | +| ---------- | ----- | +| **Goal** | Move the run journal from `/aidd_docs/runs/` to `/aidd/runs/`, keep reading the old location of every live worktree, and make forget, check and the docs follow. | +| **Source** | [`brainstorm.md`](./brainstorm.md), refining [#932](https://github.com/ai-driven-dev/framework/issues/932) | + +## Phases + +| # | Phase | File | +| --- | ----- | ---- | +| 1 | The hook writes under the common git directory | [`phase-1.md`](./phase-1.md) | +| 2 | The CLI resolves the same directory and the legacy ones | [`phase-2.md`](./phase-2.md) | +| 3 | The journal reader reads the new directory and every legacy one | [`phase-3.md`](./phase-3.md) | +| 4 | Forget removes every location, check names the real one | [`phase-4.md`](./phase-4.md) | +| 5 | End-to-end proof on real worktrees | [`phase-5.md`](./phase-5.md) | +| 6 | Decision record and docs | [`phase-6.md`](./phase-6.md) | + +Run the phases in order. Each phase ends green on its own gate before the next starts: + +- Phases 1 and 6 (`plugins/`, `scripts/`, docs): `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'`, then `pnpm exec lefthook run pre-commit`. +- Phases 2 to 5 (`cli/`): `cd cli && pnpm test`, then `pnpm exec lefthook run pre-commit` and `pnpm exec lefthook run pre-push` from the repository root. + +Never run the scripts suite bare (see `aidd_docs/memory/testing.md`). Never commit; the user commits. + +**On Windows, `lefthook run pre-commit` / `pre-push` is not a usable gate.** lefthook 2.1.14 runs each multi-line `run:` through `sh -c "…"`, and the double quotes inside the `echo` lines of `lefthook.yml` close that `-c` (`syntax error: unexpected end of file`). Jobs that use `{files}` fail with no file staged (`exec: no command`). This holds on any Windows machine, has nothing to do with this plan, and CI's `validate.yml` replays the whole pre-commit on Linux. Wherever a phase says "pre-commit" or "pre-push", run the equivalent checks directly from the repository root instead. Each must exit 0, except the known failures listed below: + +- Every phase: `node scripts/check-context-imports.js`, `node scripts/check-context-reference-form.js`, `node scripts/check-referenced-paths.js`, `node scripts/check-doc-duplication.js`. +- Phases touching `plugins/` or `scripts/` (1, 6): the wrapped scripts suite above. +- Phases touching `cli/` (2 to 5): `cd cli && pnpm lint && pnpm test:arch && pnpm typecheck && pnpm test && pnpm knip`, then `node scripts/check-cli-type-honesty.mjs` from the root. +- Phase 6 also runs `node scripts/check-markdown-links.js --ignore cli/tests/fixtures --ignore cli/aidd_docs/tasks`. + +Failures that exist without this plan, recorded on 2026-10-09: report them, never fix them, do not stop for them. + +- `check-markdown-links` and its three repository-scan tests: broken by the staged `aidd_docs/memory/README.md`. +- `check-skill-argument-hints.mjs`: no `argument-hint` in many unrelated skills. lefthook only runs it when `plugins/*/skills/**` changes, so it applies to phase 6 alone. +- `dev-sync.test.js` « managed OpenCode reload … »: depends on the environment. +- The hook perf test (budget 200 ms): already over budget without the change (see phase 1). +- `aidd-telemetry-trailer-repair.test.js` « a git that rejects --git-path still journals the session »: fails only inside the full suite. Run alone on the changed tree, it passes (`node --test --test-name-pattern="rejects --git-path" scripts/__tests__/aidd-telemetry-trailer-repair.test.js` gives `pass 1`). + +Any other failure: stop and report. + +## Rules the implementer follows everywhere + +1. **Test first.** For every behaviour, write the test, run it, and see it fail for the reason it names before touching production code (`aidd_docs/memory/coding-assertions.md`). +2. **Read the file back after every scripted edit** before running anything. +3. **Exact names.** Use the function, constant and file names written in the phases. Do not rename, do not add parameters the phase does not list. +4. **Comment style.** Match the surrounding code: a short comment saying *why*, never *what*. No comment restating the code. +5. **Out of scope, do not touch:** `telemetry-on-use-case.ts` and `manifest-gitignore-entries.ts` (they keep git-ignoring `aidd_docs/runs/`, which still protects legacy files); `task-backlog-adapter.ts` (the backlog axis stays resolved in the current checkout); `read-local-cost-use-case.ts` and `report-cost-use-case.ts` (they read only through the reader port, which phase 3 changes); the sink; any report axis. No worktree axis is added. +6. If a step's instruction contradicts what the code actually contains, stop and report the contradiction instead of improvising. + +## The location, stated once + +| Situation | Primary directory (written and read) | Legacy directories (read only, never written) | +| --- | --- | --- | +| `AIDD_RUNS_DIR` set (non-empty) | `AIDD_RUNS_DIR` | none | +| Inside a git checkout (plain, linked worktree, worktree of a bare clone, submodule) | `/aidd/runs` | `/aidd_docs/runs` of the current checkout, of the main worktree (non-bare only), and of every linked worktree still registered, each kept only when that directory exists, deduplicated, in that order | +| Hook: `git rev-parse` gave no common dir | `/aidd_docs/runs` (unchanged behaviour) | n/a | +| CLI: no `.git` above the project root | `/aidd_docs/runs` (unchanged behaviour) | none | + +`` is what `git rev-parse --git-common-dir` prints, made absolute: `/.git` for a plain checkout and for its linked worktrees, `.git` for a bare clone's worktrees. The two path segments `aidd` and `runs` are fixed. + +## Accepted edges (documented, not fixed) + +- A worktree removed before this change, never read before, stays lost. +- A session started before the upgrade keeps its old file; lines the upgraded hook writes during that same session find no file in the new directory and are dropped until the next session. +- A session resumed after the upgrade gets a second run file in the new directory; the reader prefers the new-directory file, so the pre-upgrade part of that one session loses its attribution. + +## Resources + +| Source | Verified | +| ------ | -------- | +| | A linked worktree's `.git` is a file `gitdir: `; its git dir `/worktrees/` holds a `commondir` file (path to the common dir, relative to that git dir) and a `gitdir` file (absolute path to the worktree's `.git` file). `git worktree remove` deletes the worktree tree and its `/worktrees/` entry only, and refuses an untracked file without `--force` but not an ignored one. | +| | `--git-common-dir` prints `$GIT_COMMON_DIR` if defined, else `$GIT_DIR`; relative output is relative to the current directory. | +| Local run, git 2.52.0.windows.1 | `git worktree remove` without `--force` on a worktree whose only extra content is an ignored `aidd_docs/runs/j.jsonl` exits 0 and the directory is gone. | + +## Decisions + +| Decision | Why | +| -------- | --- | +| One journal per clone, under `/aidd/runs/` | Outside every working tree (nothing to commit, no branch dirtied, survives `git worktree remove`) and present even for a bare clone. Amends the hook's per-worktree decision while keeping its two reasons. | +| Legacy `aidd_docs/runs/` of every live worktree is read, never written | Today's sessions stay visible; one authoritative copy for new lines. | +| The CLI finds the common dir by reading files, not by spawning git | `repositoryRootAbove` already walks the filesystem; staying file-based keeps the reader free of a git dependency. A parity test pins it against the hook's `git rev-parse`. | +| No worktree axis in the report | The 2026-08-31 decision requires an argument for any new axis; the issue gives none. `worktree_id` stays in the journal. | +| `telemetry-on-use-case.ts` changed after all, despite rule 5 | Its message invited deleting the `.gitignore` line to commit the journal; new journals live under the git directory, which no commit reaches, so the text had to stop promising it. Only the message changed. | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/review.md b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/review.md new file mode 100644 index 000000000..84ab10c3a --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_worktree-journal-survives/review.md @@ -0,0 +1,42 @@ +# Review: A worktree session outlives the worktree's removal + +- **Verdict**: approved +- **Diff**: [#988](https://github.com/ai-driven-dev/framework/pull/988), `fix/worktree-journal-survives` against `next`, against [#932](https://github.com/ai-driven-dev/framework/issues/932) as amended, `brainstorm.md`, `plan.md` +- **Axes run**: code, functional, relevancy +- **Rounds**: seven independent reviews; every finding was fixed and re-reviewed until the last round returned nits only, which are fixed +- **Verification**: 13/14 criteria (93 %) + +## Contract + +- [x] A session in any worktree journals under `/aidd/runs/`, the same directory for every checkout of the clone — `cli/tests/integration/run-journal-location-agrees.integration.test.ts` (main checkout, subdirectory, linked worktree, bare clone's worktree, `AIDD_RUNS_DIR`) +- [x] The journal survives `git worktree remove` without `--force` — `scripts/__tests__/aidd-telemetry-journal.test.js`, `cli/tests/e2e/telemetry-worktree-journal.e2e.test.ts` +- [x] A report from any checkout counts every worktree's sessions — the same e2e, run from the main checkout and from the linked worktree +- [x] With the sink empty and the worktree removed, the session is caught up from the tool's own transcript and attributed to its task — `telemetry-worktree-journal.e2e.test.ts`, "caught up from the tool's own transcript" +- [x] Journals left in a live checkout's `aidd_docs/runs/` are still read; the common-dir copy wins for one session — `cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts` +- [x] `forget` previews and removes every location, each on its own line, a worktree reached through a link counted once — `forget-telemetry-use-case.unit.test.ts`, `telemetry-forget-display.unit.test.ts`, `cli/tests/kernel/paths.unit.test.ts` +- [x] Only the cumulative figure is reported, no worktree axis — no `worktree` under `cli/src/contexts/telemetry/domain/report` +- [ ] A live Claude Code session in an agent's worktree — not run. The end-to-end tests replay the real hook with captured payloads, and a manual run on Windows (real hook, built CLI, real worktree, removal) attributed the session from both checkouts and after removal. + +## Mutation evidence + +| Change made on purpose | Result | +| --- | --- | +| `RUNS_UNDER_GIT_DIR = ["aidd", "run"]` on the CLI side | parity test: 4 of 5 fail, one per checkout shape | +| `legacyRunsDirs` returns `[]` | worktree e2e: the two "live worktree" cases fail | +| `resolvedRunsDir` back to `aidd_docs/runs` | every worktree e2e case fails, the transcript catch-up included | +| no per-session dedupe in the reader | both foreign-schema tests fail | +| `legacyRunsDirs` compares spelled paths again | the junction test fails (3 directories instead of 2) | + +## Gates + +- CI on the PR: every job green, `cli / gate` and `cli / Windows` included. +- Local, Windows: typecheck, `knip`, type honesty, lint on changed files, comment and catch ratchets, `check-doc-duplication`, `check-referenced-paths`, `check-markdown-links`. +- Local failures that also fail without this change and pass in CI: `commit-session-trailer` integration, persona e2e, multi-tool "reads three tools", five line-ending-driven architecture tests, the hook perf p95. + +## Accepted limits + +Recorded in `aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md`: a worktree removed before this change stays lost; an older CLI with a newer plugin sees no new session, so the plugin README asks for the CLI first; a plugin updated mid-session, or a session resumed after the update, loses part of that one session's attribution; a `--separate-git-dir` clone or a submodule does not read its main checkout's pre-move journal from a linked worktree. The plugin README cannot name the minimum CLI version until release-please tags it. + +## Out of scope + +The backlog axis reads `none` for a task folder that exists only on another worktree's unmerged branch — tracked on [#975](https://github.com/ai-driven-dev/framework/issues/975). diff --git a/cli/README.md b/cli/README.md index eca3691d9..dce3f141f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -180,7 +180,7 @@ In the project: | `.aidd/auth.json` | The project-level credential | | `.aidd/marketplaces.json` | Marketplaces registered at project scope | | `.aidd/cache/`, `.aidd/plugin-cache/` | Fetched catalogs, built trees, plugin sources | -| `aidd_docs/runs/` | The run journal, at the repository root above the project | +| `/aidd/runs/` | The run journal, shared by every worktree of the clone; older journals in a checkout's `aidd_docs/runs/` are still read | On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aidd`: diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index 2f7ca1240..d6c0b5179 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -12,7 +12,7 @@ What a session cost, measured from files each AI tool already wrote, stored per - Written by the plugin hook, not the CLI: `plugins/aidd-telemetry/hooks/journal.cjs` reads stdin, detects the host, dispatches to `hooks/lib/`. - `record.cjs` mints a run id, appends `session_start`/`turn_end`; `step-starts.cjs`, `step-ends.cjs`, `task-declared.cjs`, `file-writes.cjs` append the rest; `repo.cjs` resolves paths and tightens permissions; `trailer-repair.cjs` backs the commit trailer; `opencode-plugin.js` is OpenCode's own entry. -- Lives at the git root above the project: `kernel/paths.ts`'s `resolvedRunsDir` walks up via `repositoryRootAbove`. `AIDD_RUNS_DIR` overrides, read alike by `hooks/lib/repo.cjs` and the CLI. +- Lives under the clone's common git directory: `kernel/paths.ts`'s `resolvedRunsDir` finds it from files via `kernel/reading/git-common-dir.ts`, and `legacyRunsDirs` adds each live checkout's pre-move `aidd_docs/runs` for reading only. `AIDD_RUNS_DIR` overrides both, read alike by `hooks/lib/repo.cjs` and the CLI. ## Report diff --git a/cli/package.json b/cli/package.json index d88fbca50..6a25c4060 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 734, + "bundleBudgetKB": 745, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index c99bfadca..1f26b6e99 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -30,6 +30,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // guards for #829 (+10.4 KB over the preceding 703.8 KB); 1.1% measured headroom. // 734 KB: 725.8 KB, Kilo Code's profile, generated bridge, and runtime smoke support (+9.6 KB // over next's 716.2 KB); 1.1% measured headroom. +// 745 KB: 737.25 KB, one run journal per clone: the common git directory resolver, legacy +// journal reading and their forget preview (+3.3 KB over next's 733.98 KB); 1.1% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh index 22d718f66..40bb722e1 100644 --- a/cli/scripts/smoke-real.sh +++ b/cli/scripts/smoke-real.sh @@ -638,11 +638,11 @@ if [[ -n "${PRESENT[opencode]:-}" ]]; then cat "$tel_out" >> "$LOGFILE" # The session opens on its first call, so only a turn that completed can prove an empty # journal: exit 0 with no line is #812 itself, a model that never answered proves nothing. - if grep -qsE '"type":"session_start".*"tool":"opencode"' "$PROJ_T"/aidd_docs/runs/*.jsonl; then + if grep -qsE '"type":"session_start".*"tool":"opencode"' "$PROJ_T"/.git/aidd/runs/*.jsonl; then ok "opencode: the run journal holds a session_start line from opencode" elif [[ "$tel_rc" -eq 0 ]]; then bad "opencode: the turn completed and the run journal holds no session_start line" \ - "$(ls -la "$PROJ_T/aidd_docs/runs" 2>&1; cat "$tel_out")" + "$(ls -la "$PROJ_T/.git/aidd/runs" 2>&1; cat "$tel_out")" elif [[ "$tel_rc" -eq 142 ]]; then skip "opencode journal: the model never answered within 90s, so the journal proves nothing" elif grep -qiE "auth|api key|provider|not logged in|credential" "$tel_out"; then diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts index 2e3e101be..f8726e009 100644 --- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts +++ b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts @@ -11,7 +11,7 @@ import { import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js"; import type { InstalledPluginsReader } from "../domain/ports/installed-plugins-reader.js"; import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; -import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js"; +import type { RunJournal, RunJournalStore } from "../domain/ports/run-journal-reader.js"; import type { SessionCostReader } from "../domain/ports/session-cost-reader.js"; import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; @@ -45,7 +45,10 @@ import { type TelemetrySetup, } from "../domain/telemetry-setup.js"; -const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs"; +function runsDirLabelOf(store: RunJournalStore): string { + const legacy = store.legacyRunsDirs; + return legacy.length === 0 ? store.runsDir : `${store.runsDir} (and ${legacy.join(", ")})`; +} export interface DiagnoseTelemetryUncoveredTool { readonly tool: AiToolId; @@ -131,7 +134,7 @@ export class DiagnoseTelemetryUseCase { constructor( private readonly evidence: TelemetryEvidenceReader, private readonly git: VersionControl, - private readonly runJournalReader: RunJournalReader, + private readonly runJournalReader: RunJournalStore, private readonly readers: ReadonlyMap, private readonly hookTrustReader: HookTrustReader, private readonly personIdentityStore: PersonIdentityStore, @@ -239,7 +242,7 @@ export class DiagnoseTelemetryUseCase { return { journals: journals.map(toClaimJournal), toolReads, - runsDirLabel: DEFAULT_RUNS_DIR_LABEL, + runsDirLabel: runsDirLabelOf(this.runJournalReader), currentSessionId, unrecognisedPayloadAt: unrecognisedPayload?.at, hookTrust, diff --git a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts index 7042d1f8f..f2d95f2e7 100644 --- a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts +++ b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts @@ -26,8 +26,15 @@ export interface TelemetryRemovalOutcome { readonly failed: readonly TelemetryRemovalFailure[]; } +export interface TelemetryLegacyJournalOutcome { + readonly path: string; + readonly outcome: TelemetryRemovalOutcome; +} + export interface TelemetryRemovalResult { readonly journal: TelemetryRemovalOutcome; + readonly journalPath: string; + readonly legacyJournals: readonly TelemetryLegacyJournalOutcome[]; readonly sink: TelemetryRemovalOutcome; readonly identity: TelemetryRemovalOutcome; /** Repeated from the preview: history does not become reachable by having removed the rest, @@ -63,12 +70,23 @@ export class ForgetTelemetryUseCase { ]); return { journal: { scope: "project", path: this.runJournalReader.runsDir, runFileNames }, + legacyJournals: await this.legacyJournals(), sink: { scope: "machine", path: this.sink.rootDir, dayFileNames }, identity: { scope: "machine", path: this.identity.filePath, ...identityState }, history: this.historyReading(isRepo, tracked, hasHistory), }; } + // An empty pre-move directory is nothing to remove, so nothing to confirm. + private async legacyJournals(): Promise { + const found: TelemetryProjectJournalRemoval[] = []; + for (const path of this.runJournalReader.legacyRunsDirs) { + const runFileNames = await this.runJournalReader.listRunFilesIn(path); + if (runFileNames.length > 0) found.push({ scope: "project", path, runFileNames }); + } + return found; + } + private historyReading( isRepo: boolean, tracked: readonly string[], @@ -84,12 +102,25 @@ export class ForgetTelemetryUseCase { /** Removes exactly what `preview` resolved, never a location of its own. Every location is * attempted whatever the others did: one failure never spares or stops the rest. */ async remove(preview: TelemetryRemovalPreview): Promise { - const [journal, sink, identity] = await Promise.all([ + const [journal, legacy, sink, identity] = await Promise.all([ this.removeJournal(preview.journal), + Promise.all( + preview.legacyJournals.map(async (legacyJournal) => ({ + path: legacyJournal.path, + outcome: await this.removeJournal(legacyJournal), + })) + ), this.removeSink(preview.sink), this.removeIdentity(preview.identity), ]); - return { journal, sink, identity, history: preview.history }; + return { + journal, + journalPath: preview.journal.path, + legacyJournals: legacy, + sink, + identity, + history: preview.history, + }; } // `readStrict()` throwing is the file existing but being unreadable - exactly the file a diff --git a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts index 36c53e10a..ae224acac 100644 --- a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts +++ b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts @@ -78,7 +78,8 @@ export class TelemetryOnUseCase { this.logger.info( `Added ${RUNS_ENTRY} to .gitignore — the journal names no person, only the ` + "repository, the task folders written into, the skills run, and their timings. " + - "Delete that line to commit it instead." + "It keeps journals an earlier version wrote there out of a commit; new ones land under " + + "the clone's git directory, which no commit reaches." ); } const tracked = await this.git.listTrackedFiles(projectRoot, RUNS_ENTRY); diff --git a/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts b/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts index d261d61bf..7a04b4213 100644 --- a/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts +++ b/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts @@ -103,7 +103,7 @@ export interface RunJournal { */ export interface RunJournalReader { read(sessionId: string): Promise; - /** Every session the journal holds, for a caller with no identifier to ask about. Filtering + /** Every session the journal holds, the primary copy winning over a legacy one. Filtering * to a period is the caller's, from each journal's own `session.at`: the run file's name * carries no date. Never throws; an unreadable runs directory answers an empty list. */ list(): Promise; @@ -112,7 +112,7 @@ export interface RunJournalReader { * what removing this journal would touch needs a name a damaged file still has. Never * throws, the same failure direction as `list()`. */ listRunFiles(): Promise; - /** The schema stated by every journal this reader refused to read, one entry per file. + /** The schema stated by every journal this reader refused to read, one per session (primary wins). * `list()` drops such a journal outright, and a caller shown only that emptiness would * report a torn file about one whose header it parsed perfectly well. Empty is the ordinary * answer. Never throws, like everything else here. */ @@ -129,7 +129,11 @@ export interface RunJournalStore extends RunJournalReader { * `AIDD_RUNS_DIR`-aware resolution itself. `deleteRunFile` below is handed back this value * rather than deriving its own. */ readonly runsDir: string; - /** Removes one run file, by the name `listRunFiles()` named it with, from `dir` — mirrors + /** Each live checkout's pre-move `aidd_docs/runs`, read after `runsDir` and never written. */ + readonly legacyRunsDirs: readonly string[]; + /** Run file names directly in `dir` (`runsDir` or a legacy one), sorted; never throws. */ + listRunFilesIn(dir: string): Promise; + /** Removes one run file, by the name `listRunFiles`/`listRunFilesIn` gave it, from `dir` — mirrors * `TelemetrySink.deleteDayFile`. `dir` is never resolved here: the caller passes the exact * directory a person was already shown, so a removal can never reach one the preview never * named. `fileName` must name exactly one entry directly inside `dir`; anything else, diff --git a/cli/src/contexts/telemetry/domain/telemetry-removal.ts b/cli/src/contexts/telemetry/domain/telemetry-removal.ts index 47f74c69b..ed7a3f5a3 100644 --- a/cli/src/contexts/telemetry/domain/telemetry-removal.ts +++ b/cli/src/contexts/telemetry/domain/telemetry-removal.ts @@ -5,7 +5,7 @@ export interface TelemetryProjectJournalRemoval { readonly scope: "project"; - /** The run journal's own directory, as `RunJournalReader.runsDir` resolved it. */ + /** `RunJournalStore.runsDir`, or one of its `legacyRunsDirs`. */ readonly path: string; /** By name, never derived from parsing: a run file too damaged to parse still has a name * `readdir` can see, so it is still listed and still removed. */ @@ -46,6 +46,7 @@ export type TelemetryHistoryReading = * caller cannot render one without the other. */ export interface TelemetryRemovalPreview { readonly journal: TelemetryProjectJournalRemoval; + readonly legacyJournals: readonly TelemetryProjectJournalRemoval[]; readonly sink: TelemetryMachineSinkRemoval; readonly identity: TelemetryMachineIdentityRemoval; readonly history: TelemetryHistoryReading; @@ -55,6 +56,7 @@ export interface TelemetryRemovalPreview { export function telemetryRemovalIsEmpty(preview: TelemetryRemovalPreview): boolean { return ( preview.journal.runFileNames.length === 0 && + preview.legacyJournals.every((legacy) => legacy.runFileNames.length === 0) && preview.sink.dayFileNames.length === 0 && !preview.identity.present ); diff --git a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts index 9d7c55511..f7e0f4631 100644 --- a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts @@ -1,6 +1,6 @@ import { readdir, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; -import { resolvedRunsDir } from "../../../kernel/paths.js"; +import { legacyRunsDirs, resolvedRunsDir } from "../../../kernel/paths.js"; import { isBareFileName } from "../../../kernel/reading/confined-file-name.js"; import type { RunJournal, @@ -29,12 +29,16 @@ export function sanitizePathSegment(segment: string): string { // Mirrors record.cjs's parseRunFileName: split on the fixed ULID length, never on "__", // since a sanitized vendor id can itself contain that substring. -function matchesVendorId(entry: string, wantedSegment: string): boolean { - if (!entry.endsWith(RUN_FILE_EXTENSION)) return false; +function vendorSegmentOf(entry: string): string | null { + if (!entry.endsWith(RUN_FILE_EXTENSION)) return null; const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; - if (entry.length <= minLength) return false; - if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return false; - return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length) === wantedSegment; + if (entry.length <= minLength) return null; + if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return null; + return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length); +} + +function matchesVendorId(entry: string, wantedSegment: string): boolean { + return vendorSegmentOf(entry) === wantedSegment; } function asString(value: unknown): string | undefined { @@ -201,32 +205,50 @@ function classifyLine(collector: JournalCollector, parsed: RawJournalLine): void /** Never throws: a missing run file, an unreadable runs directory or a truncated final line * all answer `null` or an empty list, since a damaged journal costs attribution, not the read - * itself. `AIDD_RUNS_DIR` overrides the directory, resolved once in the constructor so a - * later relocation cannot change what this instance answers. */ + * itself. `runsDir` is read first, then each legacy directory; a session already found is + * skipped. Both resolve once in the constructor (`AIDD_RUNS_DIR` overriding), so a later + * relocation cannot change what this instance answers. */ export class RunJournalReaderAdapter implements RunJournalStore { readonly runsDir: string; + readonly legacyRunsDirs: readonly string[]; constructor(projectRoot: string) { this.runsDir = resolvedRunsDir(projectRoot); + this.legacyRunsDirs = legacyRunsDirs(projectRoot); + } + + private get readDirs(): readonly string[] { + return [this.runsDir, ...this.legacyRunsDirs]; } async read(sessionId: string): Promise { - const filePath = await this.findRunFile(this.runsDir, sessionId); - return filePath ? this.readJournal(filePath) : null; + for (const dir of this.readDirs) { + const filePath = await this.findRunFile(dir, sessionId); + if (filePath) return this.readJournal(filePath); + } + return null; } - async list(): Promise { - const dir = this.runsDir; - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return []; + private async firstRunFilePerSession(): Promise { + const paths: string[] = []; + const seen = new Set(); + for (const dir of this.readDirs) { + for (const entry of await this.listRunFilesIn(dir)) { + const segment = vendorSegmentOf(entry); + if (segment !== null) { + if (seen.has(segment)) continue; + seen.add(segment); + } + paths.push(join(dir, entry)); + } } + return paths; + } + + async list(): Promise { const journals: RunJournal[] = []; - for (const entry of entries.sort()) { - if (!entry.endsWith(RUN_FILE_EXTENSION)) continue; - const journal = await this.readJournal(join(dir, entry)); + for (const filePath of await this.firstRunFilePerSession()) { + const journal = await this.readJournal(filePath); if (journal) journals.push(journal); } return journals; @@ -234,9 +256,8 @@ export class RunJournalReaderAdapter implements RunJournalStore { async listForeignSchemas(): Promise { const stated: number[] = []; - for (const fileName of await this.listRunFiles()) { - const collector = await this.collect(join(this.runsDir, fileName)); - const version = collector?.session?.schema_version; + for (const filePath of await this.firstRunFilePerSession()) { + const version = (await this.collect(filePath))?.session?.schema_version; if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION) stated.push(version); } @@ -244,8 +265,12 @@ export class RunJournalReaderAdapter implements RunJournalStore { } async listRunFiles(): Promise { + return this.listRunFilesIn(this.runsDir); + } + + async listRunFilesIn(dir: string): Promise { try { - const entries = await readdir(this.runsDir); + const entries = await readdir(dir); return entries.filter((entry) => entry.endsWith(RUN_FILE_EXTENSION)).sort(); } catch { return []; diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 4c1e3914b..aef314a0f 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -1,4 +1,6 @@ -import { join, posix, win32 } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; +import { join, posix, resolve, win32 } from "node:path"; +import { gitCommonDirAbove, worktreeRootsOf } from "./reading/git-common-dir.js"; import { repositoryRootAbove } from "./reading/repository-root.js"; export const AIDD_DIR = ".aidd"; @@ -21,14 +23,39 @@ export const BUILT_CACHE_SUBDIR = join(AIDD_DIR, "cache", "built"); // `VersionControl.listTrackedFiles` about exactly this path. export const RUNS_ENTRY = `${DOCS_DIR}/${RUNS_SUBDIR}/`; -/** - * Where the run journal lives — at the repository root above `projectRoot`, never - * `projectRoot` itself, because the hook that writes it anchors there (`repositoryRootAbove` - * carries why). The one resolver, so two readers cannot disagree from a subdirectory. - * `AIDD_RUNS_DIR` overrides it outright, matching the hook. - */ +const RUNS_UNDER_GIT_DIR = ["aidd", "runs"] as const; + +/** Under the clone's common git directory, shared by its worktrees and kept by `git worktree + * remove`; the hook's `repo.cjs` spells the same place. `AIDD_RUNS_DIR` overrides it. */ export function resolvedRunsDir(projectRoot: string): string { - return process.env.AIDD_RUNS_DIR || join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); + if (process.env.AIDD_RUNS_DIR) return process.env.AIDD_RUNS_DIR; + const commonDir = gitCommonDirAbove(projectRoot); + return commonDir + ? join(commonDir, ...RUNS_UNDER_GIT_DIR) + : join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); +} + +function realPath(dir: string): string { + try { + return realpathSync.native(dir); + } catch { + return resolve(dir); + } +} + +/** Each live checkout's pre-move `aidd_docs/runs`, current one first: read, never written. */ +export function legacyRunsDirs(projectRoot: string): readonly string[] { + if (process.env.AIDD_RUNS_DIR) return []; + const commonDir = gitCommonDirAbove(projectRoot); + if (commonDir === null) return []; + const dirs: string[] = []; + for (const root of [repositoryRootAbove(projectRoot), ...worktreeRootsOf(commonDir)]) { + const dir = join(root, DOCS_DIR, RUNS_SUBDIR); + if (!existsSync(dir) || dirs.some((known) => samePath(realPath(known), realPath(dir)))) + continue; + dirs.push(dir); + } + return dirs; } export function marketplaceCacheDir(projectRoot: string, marketplaceName: string): string { diff --git a/cli/src/kernel/reading/git-common-dir.ts b/cli/src/kernel/reading/git-common-dir.ts new file mode 100644 index 000000000..5ec33ddb3 --- /dev/null +++ b/cli/src/kernel/reading/git-common-dir.ts @@ -0,0 +1,64 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { repositoryRootAbove } from "./repository-root.js"; + +const GIT_DIR_NAME = ".git"; +const GITDIR_POINTER = /^gitdir:\s*(.+?)\s*$/mu; + +/** What `git rev-parse --git-common-dir` answers for `start`, absolute, read from files rather + * than by spawning git; `null` outside any checkout. */ +export function gitCommonDirAbove(start: string): string | null { + const root = repositoryRootAbove(start); + const dotGit = join(root, GIT_DIR_NAME); + let isDirectory: boolean; + try { + isDirectory = statSync(dotGit).isDirectory(); + } catch { + return null; + } + if (isDirectory) return dotGit; + const gitDir = readGitDirPointer(root, dotGit); + if (gitDir === null) return null; + try { + return resolve(gitDir, readFileSync(join(gitDir, "commondir"), "utf8").trim()); + } catch { + return gitDir; + } +} + +function readGitDirPointer(root: string, dotGitFile: string): string | null { + try { + const match = GITDIR_POINTER.exec(readFileSync(dotGitFile, "utf8")); + return match?.[1] ? resolve(root, match[1]) : null; + } catch { + return null; + } +} + +function linkedWorktreeRoot(worktreesDir: string, entry: string): string | null { + try { + const pointer = readFileSync(join(worktreesDir, entry, "gitdir"), "utf8").trim(); + const root = dirname(resolve(worktreesDir, entry, pointer)); + return existsSync(root) ? root : null; + } catch { + return null; + } +} + +/** The main working tree (non-bare clones only), then every linked worktree still on disk. */ +export function worktreeRootsOf(commonDir: string): readonly string[] { + const roots: string[] = []; + if (basename(commonDir) === GIT_DIR_NAME) roots.push(dirname(commonDir)); + const worktreesDir = join(commonDir, "worktrees"); + let entries: string[]; + try { + entries = readdirSync(worktreesDir); + } catch { + return roots; + } + for (const entry of entries.sort()) { + const root = linkedWorktreeRoot(worktreesDir, entry); + if (root !== null) roots.push(root); + } + return roots; +} diff --git a/cli/src/presentation/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts index 24148e2db..06b1ca12d 100644 --- a/cli/src/presentation/display/telemetry-display.ts +++ b/cli/src/presentation/display/telemetry-display.ts @@ -81,7 +81,8 @@ export function printTelemetryOffReport(output: CLIOutput, result: TelemetryOffR const switchLabel = result.switchChanged ? "off" : "already off"; output.success(`AIDD telemetry: ${switchLabel} (${result.switchPath})`); output.info( - "This stops new recording only — sessions already journalled stay in aidd_docs/runs/ " + + "This stops new recording only — sessions already journalled stay under the clone's git " + + "directory (or an earlier version's aidd_docs/runs/) " + "and whatever `aidd telemetry read` already stored, and `aidd telemetry report` still " + "reports them. Run `aidd telemetry forget` to remove what was already measured." ); diff --git a/cli/src/presentation/display/telemetry-forget-display.ts b/cli/src/presentation/display/telemetry-forget-display.ts index e7a9321d4..821cf5d51 100644 --- a/cli/src/presentation/display/telemetry-forget-display.ts +++ b/cli/src/presentation/display/telemetry-forget-display.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import type { TelemetryRemovalFailure, TelemetryRemovalOutcome, @@ -81,6 +82,11 @@ export function printTelemetryForgetPreview( ` This project's run journal (${preview.journal.path}): ` + `${preview.journal.runFileNames.length} run file(s)` ); + for (const legacy of preview.legacyJournals) { + output.print( + ` ${earlierJournalLabel(legacy.path)}: ` + `${legacy.runFileNames.length} run file(s)` + ); + } output.print( ` This machine's stored records — every project measured on this machine ` + `(${preview.sink.path}): ${preview.sink.dayFileNames.length} day file(s)` @@ -110,6 +116,17 @@ function outcomeLine(label: string, outcome: TelemetryRemovalOutcome): string { return ` ${label}: ${outcome.removed} removed${failedNote}`; } +function inDirectory( + dir: string, + failures: readonly TelemetryRemovalFailure[] +): readonly TelemetryRemovalFailure[] { + return failures.map((failure) => ({ ...failure, path: join(dir, failure.path) })); +} + +function earlierJournalLabel(path: string): string { + return `An earlier run journal, from before it moved under the git directory (${path})`; +} + /** What went, and what did not — in counts a person can check against * `printTelemetryForgetPreview`'s own counts. */ export function printTelemetryForgetResult( @@ -118,9 +135,19 @@ export function printTelemetryForgetResult( ): void { output.success("AIDD telemetry: removed"); output.print(outcomeLine("This project's run journal", result.journal)); + for (const legacy of result.legacyJournals) { + output.print(outcomeLine(earlierJournalLabel(legacy.path), legacy.outcome)); + } output.print(outcomeLine("This machine's stored records", result.sink)); output.print(outcomeLine("This machine's identity", result.identity)); - printFailures(output, "journal run file", result.journal.failed); + printFailures(output, "journal run file", inDirectory(result.journalPath, result.journal.failed)); + for (const legacy of result.legacyJournals) { + printFailures( + output, + "earlier journal run file", + inDirectory(legacy.path, legacy.outcome.failed) + ); + } printFailures(output, "sink day file", result.sink.failed); printFailures(output, "identity file", result.identity.failed); printHistory(output, result.history); diff --git a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts index 0b19c9176..ba331a495 100644 --- a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts @@ -941,11 +941,22 @@ describe("DiagnoseTelemetryUseCase — what each journal contributes to the clai verdict: "fail", reason: "recorder-declared-nowhere", detail: - "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "no run file in /fake/project/aidd_docs/runs — the hook has never been observed firing, and the " + "recorder is declared nowhere this build checks", }); }); + it("names every directory it read run files from, an earlier version's included", async () => { + const { useCase, journalReader } = buildUseCase({}); + journalReader.legacyRunsDirs = ["/fake/wt/aidd_docs/runs"]; + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[0]?.detail).toMatch( + /^no run file in \/fake\/project\/aidd_docs\/runs \(and \/fake\/wt\/aidd_docs\/runs\) — /u + ); + }); + it("never asks Codex's hook trust for a session another tool anchors", async () => { const hookTrustReader = new StubHookTrustReader(); const { useCase } = buildUseCase({ hookTrustReader }); diff --git a/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts index 36c4fc811..006444908 100644 --- a/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts @@ -46,6 +46,16 @@ describe("ForgetTelemetryUseCase.preview() — every location, resolved once, an }); }); + it("previews every legacy journal that holds run files, and leaves out an empty one", async () => { + const { runJournalReader, useCase } = buildUseCase(); + runJournalReader.legacyRunsDirs = ["/fake/wt/aidd_docs/runs", "/fake/empty/aidd_docs/runs"]; + runJournalReader.legacyRunFileNames.set("/fake/wt/aidd_docs/runs", ["x.jsonl", "y.jsonl"]); + const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); + expect(preview.legacyJournals).toEqual([ + { scope: "project", path: "/fake/wt/aidd_docs/runs", runFileNames: ["x.jsonl", "y.jsonl"] }, + ]); + }); + it("names the sink as this machine's own, spanning whatever it holds", async () => { const { sink, useCase } = buildUseCase(); await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); @@ -185,6 +195,38 @@ describe("ForgetTelemetryUseCase.remove() — acts on the value preview() produc expect(await identity.read()).toBeNull(); }); + it("removes the previewed legacy files from their own directory and reports them per directory", async () => { + const { runJournalReader, useCase } = buildUseCase(); + const legacy = "/fake/wt/aidd_docs/runs"; + runJournalReader.runFileNames = ["p.jsonl"]; + runJournalReader.legacyRunsDirs = [legacy]; + runJournalReader.legacyRunFileNames.set(legacy, ["x.jsonl", "y.jsonl"]); + const result = await useCase.remove(await useCase.preview({ projectRoot: PROJECT_ROOT })); + expect(result.journal.removed).toBe(1); + expect(result.legacyJournals).toEqual([{ path: legacy, outcome: { removed: 2, failed: [] } }]); + expect(runJournalReader.deletedFromDirs.filter((dir) => dir === legacy)).toHaveLength(2); + expect( + runJournalReader.deletedFromDirs.filter((dir) => dir === runJournalReader.runsDir) + ).toHaveLength(1); + expect(runJournalReader.legacyRunFileNames.get(legacy)).toEqual([]); + }); + + it("reports a legacy file that refuses removal by its name and still removes the rest", async () => { + const { runJournalReader, useCase } = buildUseCase(); + const legacy = "/fake/wt/aidd_docs/runs"; + runJournalReader.legacyRunsDirs = [legacy]; + runJournalReader.legacyRunFileNames.set(legacy, ["x.jsonl", "y.jsonl"]); + runJournalReader.undeletable.add("x.jsonl"); + const result = await useCase.remove(await useCase.preview({ projectRoot: PROJECT_ROOT })); + expect(result.journal).toEqual({ removed: 0, failed: [] }); + expect(result.legacyJournals).toEqual([ + { + path: legacy, + outcome: { removed: 1, failed: [{ path: "x.jsonl", reason: "cannot delete x.jsonl" }] }, + }, + ]); + }); + it("a location that refuses removal is reported, and every other location is still emptied", async () => { const { sink, runJournalReader, identity, useCase } = buildUseCase(); await sink.appendRecord(RECORD, new Date("2026-08-19T00:00:00.000Z")); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts index 4b604e524..3f1acf71f 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts @@ -259,7 +259,8 @@ describe("TelemetryOnUseCase — what it says, word for word", () => { const IGNORED_LINE = "Added aidd_docs/runs/ to .gitignore — the journal names no person, only the " + "repository, the task folders written into, the skills run, and their timings. " + - "Delete that line to commit it instead."; + "It keeps journals an earlier version wrote there out of a commit; new ones land under " + + "the clone's git directory, which no commit reaches."; it("names the command in the refusal it throws without --yes", async () => { const { useCase } = buildUseCase(); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts index b21f7f68d..48d300ca5 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts @@ -7,6 +7,7 @@ import { function preview(overrides: Partial = {}): TelemetryRemovalPreview { return { journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, + legacyJournals: [], sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, identity: { scope: "machine", @@ -56,6 +57,18 @@ describe("telemetryRemovalIsEmpty()", () => { ).toBe(false); }); + it("is not empty when only a legacy journal holds run files", () => { + expect( + telemetryRemovalIsEmpty( + preview({ + legacyJournals: [ + { scope: "project", path: "/wt/aidd_docs/runs", runFileNames: ["a.jsonl"] }, + ], + }) + ) + ).toBe(false); + }); + it("is not empty when the sink holds a day file", () => { expect( telemetryRemovalIsEmpty( diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts index 1c93d46a5..26e105dd1 100644 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts @@ -25,7 +25,7 @@ describe("file_written, from the hook that writes it to the reader that reads it execFileSync("git", ["init", "-q", projectRoot], { env: environmentWithoutGitVariables(process.env), }); - runsDir = join(projectRoot, "aidd_docs", "runs"); + runsDir = join(projectRoot, ".git", "aidd", "runs"); await mkdir(runsDir, { recursive: true }); await mkdir(join(projectRoot, "aidd_docs", "tasks", "2026_08", "2026_08_21_cost-reporter"), { recursive: true, diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts index b8e7af05c..e46aa0455 100644 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts @@ -1,17 +1,20 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { READABLE_JOURNAL_SCHEMA_VERSION, RunJournalReaderAdapter, sanitizePathSegment, } from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; +import { samePath } from "../../../../src/kernel/paths.js"; import { journalRecord, journalRepo } from "../../../helpers/telemetry-journal-hook.js"; // A real-shaped ULID (26 Crockford-base32 characters): the adapter splits a run file's name // on that fixed length, never on "__", so the id itself must be genuine. const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const OTHER_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAW"; +const LEGACY_SESSION_ID = "33333333-3333-4333-8333-333333333333"; const SESSION_ID = "22222222-2222-4222-8222-222222222222"; function runFileLines(...lines: readonly unknown[]): string { @@ -21,6 +24,7 @@ function runFileLines(...lines: readonly unknown[]): string { describe("RunJournalReaderAdapter", () => { let projectRoot: string; let runsDir: string; + const extraDirs: string[] = []; beforeEach(async () => { projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-")); @@ -30,6 +34,7 @@ describe("RunJournalReaderAdapter", () => { afterEach(async () => { await rm(projectRoot, { recursive: true, force: true }); + for (const dir of extraDirs.splice(0)) await rm(dir, { recursive: true, force: true }); delete process.env.AIDD_RUNS_DIR; }); @@ -211,6 +216,149 @@ describe("RunJournalReaderAdapter", () => { ]); await rm(overrideDir, { recursive: true, force: true }); }); + + // Registered the way git records a linked worktree: `.git/worktrees/wt/gitdir` names it. + async function registerWorktree(other: string): Promise { + const entry = join(projectRoot, ".git", "worktrees", "wt"); + await mkdir(entry, { recursive: true }); + await mkdir(other, { recursive: true }); + await writeFile(join(other, ".git"), `gitdir: ${entry}\n`); + await writeFile(join(entry, "gitdir"), `${join(other, ".git")}\n`); + await writeFile(join(entry, "commondir"), "../..\n"); + } + + const commonRuns = (): string => join(projectRoot, ".git", "aidd", "runs"); + + function sessionStart(runId: string, vendorId: string, schemaVersion = 2): unknown { + return { + type: "session_start", + at: "2026-08-20T09:00:00Z", + schema_version: schemaVersion, + run_id: runId, + tool: "claude-code", + vendor_id: vendorId, + }; + } + + it("reads a journal written under the common git directory", async () => { + await mkdir(commonRuns(), { recursive: true }); + await writeFile( + join(commonRuns(), `${RUN_ID}__${SESSION_ID}.jsonl`), + runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-common-dir" }) + ); + + const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + + expect(journal?.boundaries).toEqual([ + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-common-dir" }, + ]); + }); + + it("lists a journal still sitting in another live worktree's aidd_docs/runs", async () => { + await mkdir(join(projectRoot, ".git"), { recursive: true }); + const other = await mkdtemp(join(tmpdir(), "aidd-run-journal-other-")); + extraDirs.push(other); + await registerWorktree(other); + await mkdir(join(other, "aidd_docs", "runs"), { recursive: true }); + await writeFile( + join(other, "aidd_docs", "runs", `${OTHER_RUN_ID}__${LEGACY_SESSION_ID}.jsonl`), + runFileLines(sessionStart(OTHER_RUN_ID, LEGACY_SESSION_ID)) + ); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journals = await adapter.list(); + + expect(journals.map((journal) => journal.session?.vendor_id)).toEqual([LEGACY_SESSION_ID]); + expect(await adapter.read(LEGACY_SESSION_ID)).not.toBeNull(); + }); + + it("prefers the common git directory's copy when one session has a file in both", async () => { + await mkdir(commonRuns(), { recursive: true }); + const name = `${RUN_ID}__${SESSION_ID}.jsonl`; + await writeFile( + join(commonRuns(), name), + runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "primary" }) + ); + await writeFile( + join(runsDir, name), + runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "legacy" }) + ); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journals = await adapter.list(); + + expect((await adapter.read(SESSION_ID))?.boundaries).toEqual([ + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "primary" }, + ]); + expect(journals.map((journal) => journal.boundaries)).toEqual([ + [{ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "primary" }], + ]); + }); + + it("reads nothing but AIDD_RUNS_DIR when it is set, even with legacy journals present", async () => { + await mkdir(join(projectRoot, ".git"), { recursive: true }); + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + runFileLines(sessionStart(RUN_ID, SESSION_ID)) + ); + const overrideDir = await mkdtemp(join(tmpdir(), "aidd-run-journal-override-")); + extraDirs.push(overrideDir); + process.env.AIDD_RUNS_DIR = overrideDir; + await writeFile( + join(overrideDir, `${OTHER_RUN_ID}__${LEGACY_SESSION_ID}.jsonl`), + runFileLines(sessionStart(OTHER_RUN_ID, LEGACY_SESSION_ID)) + ); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journals = await adapter.list(); + + expect(journals.map((journal) => journal.session?.vendor_id)).toEqual([LEGACY_SESSION_ID]); + expect(adapter.legacyRunsDirs).toEqual([]); + }); + + it("reports a foreign schema found in a legacy directory", async () => { + await mkdir(join(projectRoot, ".git"), { recursive: true }); + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + runFileLines(sessionStart(RUN_ID, SESSION_ID, 99)) + ); + + expect(await new RunJournalReaderAdapter(projectRoot).listForeignSchemas()).toEqual([99]); + }); + + it("reports a foreign schema once when one session has a file in both places", async () => { + await mkdir(commonRuns(), { recursive: true }); + const name = `${RUN_ID}__${SESSION_ID}.jsonl`; + await writeFile(join(commonRuns(), name), runFileLines(sessionStart(RUN_ID, SESSION_ID, 99))); + await writeFile(join(runsDir, name), runFileLines(sessionStart(RUN_ID, SESSION_ID, 99))); + + expect(await new RunJournalReaderAdapter(projectRoot).listForeignSchemas()).toEqual([99]); + }); + + it("judges a session's schema by its common-dir copy, not by a stale legacy one", async () => { + await mkdir(commonRuns(), { recursive: true }); + const name = `${RUN_ID}__${SESSION_ID}.jsonl`; + await writeFile(join(commonRuns(), name), runFileLines(sessionStart(RUN_ID, SESSION_ID, 2))); + await writeFile(join(runsDir, name), runFileLines(sessionStart(RUN_ID, SESSION_ID, 99))); + + expect(await new RunJournalReaderAdapter(projectRoot).listForeignSchemas()).toEqual([]); + }); + + it("names a legacy directory's run files by name", async () => { + await mkdir(join(projectRoot, ".git"), { recursive: true }); + await writeFile(join(runsDir, `${RUN_ID}__a.jsonl`), "x\n"); + await writeFile(join(runsDir, `${OTHER_RUN_ID}__b.jsonl`), "x\n"); + await writeFile(join(runsDir, "notes.txt"), "x\n"); + const adapter = new RunJournalReaderAdapter(projectRoot); + + expect(adapter.legacyRunsDirs).toHaveLength(1); + const [legacy] = adapter.legacyRunsDirs; + expect(samePath(resolve(legacy ?? ""), resolve(runsDir))).toBe(true); + expect(await adapter.listRunFilesIn(legacy ?? "")).toEqual( + [`${RUN_ID}__a.jsonl`, `${OTHER_RUN_ID}__b.jsonl`].sort() + ); + expect(await adapter.listRunFiles()).toEqual([]); + }); }); // Duplicated on purpose, not shared at runtime: this keeps the copy honest, so the hook's diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts index c852ad31e..a6c65710f 100644 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts @@ -24,7 +24,7 @@ describe("task_declared, from the hook that writes it to the reader that reads i execFileSync("git", ["init", "-q", projectRoot], { env: environmentWithoutGitVariables(process.env), }); - runsDir = join(projectRoot, "aidd_docs", "runs"); + runsDir = join(projectRoot, ".git", "aidd", "runs"); await mkdir(runsDir, { recursive: true }); await mkdir(join(projectRoot, ".aidd"), { recursive: true }); await writeFile( diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts index 2a94dbdd0..88f391b4d 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts @@ -499,13 +499,13 @@ describe("a payload that matched no known host", () => { }); }); - // The hook writing this file anchors at the repository root, never at the directory a + // The hook writes this file under the common git directory, never beside the directory a // session started from, so a reader must walk up rather than join onto `projectRoot`. it("finds the file from a subdirectory of the repository, not only from its root", async () => { const root = project(); mkdirSync(join(root, ".git"), { recursive: true }); write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + join(root, ".git", "aidd", "runs", "_unrecognised.jsonl"), `${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` ); const subdirectory = join(root, "packages", "app"); diff --git a/cli/tests/e2e/telemetry-check.e2e.test.ts b/cli/tests/e2e/telemetry-check.e2e.test.ts index 3f9afefa1..686eedb2c 100644 --- a/cli/tests/e2e/telemetry-check.e2e.test.ts +++ b/cli/tests/e2e/telemetry-check.e2e.test.ts @@ -57,7 +57,7 @@ async function seedTornRunFile(projectDir: string): Promise { } async function seedUnrecognisedPayload(projectDir: string, at: string): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); + const runsDir = join(projectDir, ".git", "aidd", "runs"); await mkdir(runsDir, { recursive: true }); await writeFile( join(runsDir, "_unrecognised.jsonl"), diff --git a/cli/tests/e2e/telemetry-forget.e2e.test.ts b/cli/tests/e2e/telemetry-forget.e2e.test.ts index 4d96c55cf..bdd36a460 100644 --- a/cli/tests/e2e/telemetry-forget.e2e.test.ts +++ b/cli/tests/e2e/telemetry-forget.e2e.test.ts @@ -207,7 +207,7 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-refused-real"); try { await gitInit(projectDir); - const runsDir = join(projectDir, "aidd_docs", "runs"); + const runsDir = join(projectDir, ".git", "aidd", "runs"); await mkdir(join(runsDir, "adir.jsonl"), { recursive: true }); await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", sinkRecordLine("s-1")); await seedIdentity(fakeHome, "11111111-1111-1111-1111-111111111111"); @@ -218,7 +218,9 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his expect(result.stdout).toMatch( /This project's run journal: 0 removed, 1 could not be removed/u ); - expect(result.stderr).toMatch(/Could not remove journal run file adir\.jsonl/u); + expect(result.stderr).toMatch( + /Could not remove journal run file \S*aidd[\\/]runs[\\/]adir\.jsonl/u + ); expect(await entries(runsDir)).toEqual(["adir.jsonl"]); expect(result.stdout).toMatch(/This machine's stored records: 1 removed/u); expect(result.stdout).toMatch(/This machine's identity: 1 removed/u); @@ -266,7 +268,10 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome); expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/This project's run journal: 1 removed/u); + expect(result.stdout).toMatch(/This project's run journal: 0 removed/u); + expect(result.stdout).toMatch( + /An earlier run journal, from before it moved under the git directory \(.+\): 1 removed/u + ); expect(result.stdout).toMatch(/This machine's stored records: 2 removed/u); expect(result.stdout).toMatch(/This machine's identity: 1 removed/u); expect(await entries(join(projectDir, "aidd_docs", "runs"))).toEqual([]); diff --git a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts b/cli/tests/e2e/telemetry-hook-install.e2e.test.ts index 6ef56274f..622aba0c5 100644 --- a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts +++ b/cli/tests/e2e/telemetry-hook-install.e2e.test.ts @@ -56,11 +56,11 @@ describe("E2E: the journal hook runs from where installation puts it", () => { const { stderr } = await hook; expect(stderr).toBe(""); - const written = readdirSync(join(projectDir, "aidd_docs", "runs")); + const written = readdirSync(join(projectDir, ".git", "aidd", "runs")); expect(written).toHaveLength(1); expect(written[0]).toMatch(/\.jsonl$/); const lines = readFileSync( - join(projectDir, "aidd_docs", "runs", written[0] as string), + join(projectDir, ".git", "aidd", "runs", written[0] as string), "utf-8" ) .split("\n") diff --git a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts b/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts index 8bdcc7428..77e5b2185 100644 --- a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts +++ b/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts @@ -74,7 +74,7 @@ describe("aidd setup never offers the run journal to a commit", () => { transcript_path: `${fakeHome}/.claude/projects/fake/x.jsonl`, }); - const written = await readdir(`${projectDir}/aidd_docs/runs`); + const written = await readdir(`${projectDir}/.git/aidd/runs`); expect( written.some((f) => f.endsWith(".jsonl")), "the hook did not actually journal" diff --git a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts index 310c1385c..d156213e5 100644 --- a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts +++ b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts @@ -130,14 +130,14 @@ describe("measurement, from nothing to off and back", () => { } async function runFiles(): Promise { - const dir = join(projectDir, "aidd_docs", "runs"); + const dir = join(projectDir, ".git", "aidd", "runs"); return existsSync(dir) ? await readdir(dir) : []; } /** Counting files would miss a session that carries on: a run file is named for its * session, so a second turn appends to the file the first turn opened. */ async function journalLines(): Promise { - const dir = join(projectDir, "aidd_docs", "runs"); + const dir = join(projectDir, ".git", "aidd", "runs"); let total = 0; for (const name of await runFiles()) { total += (await readFile(join(dir, name), "utf8")).trim().split("\n").filter(Boolean).length; diff --git a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts b/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts index 59ff480b4..524947156 100644 --- a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts +++ b/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts @@ -270,9 +270,12 @@ describe("aidd telemetry, across every tool that can be read", () => { await journalCodexSessionBackdated(); await readEveryTool(); - const runFiles = await readdir(join(projectDir, "aidd_docs", "runs")); + const runFiles = await readdir(join(projectDir, ".git", "aidd", "runs")); const claudeRun = runFiles.find((name) => name.endsWith(`__${CLAUDE_SESSION}.jsonl`)); - const journal = await readFile(join(projectDir, "aidd_docs", "runs", claudeRun ?? ""), "utf8"); + const journal = await readFile( + join(projectDir, ".git", "aidd", "runs", claudeRun ?? ""), + "utf8" + ); expect(journal).toContain('"file_written"'); const result = await cli([ diff --git a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts b/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts index ce7b7765f..30e2e9a79 100644 --- a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts +++ b/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts @@ -93,12 +93,12 @@ describe("the plugin measures on its own", () => { tool_input: { skill: "aidd-dev:02-implement" }, }); - const journals = readdirSync(join(projectDir, "aidd_docs", "runs")).filter((name) => + const journals = readdirSync(join(projectDir, ".git", "aidd", "runs")).filter((name) => name.endsWith(".jsonl") ); expect(journals).toHaveLength(1); - const lines = readFileSync(join(projectDir, "aidd_docs", "runs", journals[0] ?? ""), "utf8") + const lines = readFileSync(join(projectDir, ".git", "aidd", "runs", journals[0] ?? ""), "utf8") .trim() .split("\n") .map((line) => JSON.parse(line) as { type: string }); diff --git a/cli/tests/e2e/telemetry-refusal.e2e.test.ts b/cli/tests/e2e/telemetry-refusal.e2e.test.ts index e38079afd..408f147d4 100644 --- a/cli/tests/e2e/telemetry-refusal.e2e.test.ts +++ b/cli/tests/e2e/telemetry-refusal.e2e.test.ts @@ -36,7 +36,7 @@ describe("a person's own refusal, without touching a tracked file", () => { async function runFileCount(projectDir: string): Promise { try { - const files = await readdir(`${projectDir}/aidd_docs/runs`); + const files = await readdir(`${projectDir}/.git/aidd/runs`); return files.filter((f) => f.endsWith(".jsonl")).length; } catch { return 0; diff --git a/cli/tests/e2e/telemetry-worktree-journal.e2e.test.ts b/cli/tests/e2e/telemetry-worktree-journal.e2e.test.ts new file mode 100644 index 000000000..fc16fd64b --- /dev/null +++ b/cli/tests/e2e/telemetry-worktree-journal.e2e.test.ts @@ -0,0 +1,239 @@ +import { execFileSync } from "node:child_process"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, gitInit, runCli } from "./helpers.js"; + +const PROJECT_ID = "acme/worktrees"; +const PERIOD = ["--from", "2026-02-01", "--to", "2026-02-28"]; +const ALPHA_TASK = "2026_02/2026_02_10_alpha"; +const BETA_TASK = "2026_02/2026_02_10_beta"; +const RUN_A = "01ARZ3NDEKTSV4RRFFQ69G5FA1"; +const VENDOR_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const RUN_B = "01ARZ3NDEKTSV4RRFFQ69G5FB1"; +const VENDOR_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const WORKTREE_NAME = "agent-wt"; + +function git(cwd: string, args: readonly string[]): void { + execFileSync("git", ["-c", "user.email=t@example.com", "-c", "user.name=Test", ...args], { + cwd, + env: environmentWithoutGitVariables(process.env), + }); +} + +function journal(runId: string, vendorId: string, task: string, startAt: string): string { + const lines = [ + { + type: "session_start", + at: startAt, + schema_version: 2, + run_id: runId, + tool: "codex", + vendor_id: vendorId, + project_id: PROJECT_ID, + worktree_id: WORKTREE_NAME, + worktree_repo_id: "project", + }, + { + type: "task_declared", + at: startAt.replace(":00:00Z", ":10:00Z"), + path: `aidd_docs/tasks/${task}/spec.md`, + }, + { + type: "file_written", + at: startAt.replace(":00:00Z", ":40:00Z"), + path: `aidd_docs/tasks/${task}/plan.md`, + }, + ]; + return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`; +} + +function record(overrides: Record): Record { + return { + kind: "request", + sink_schema_version: 2, + provenance: "local-read", + tool: "codex", + vendor_field: "session_meta.id", + step_attribution: "unattributed", + project_id: PROJECT_ID, + ...overrides, + }; +} + +const RECORDS = [ + record({ + vendor_id: VENDOR_A, + turn_id: "a", + event_timestamp: "2026-02-10T09:20:00Z", + cost_usd: 2, + }), + record({ + vendor_id: VENDOR_B, + turn_id: "b", + event_timestamp: "2026-02-10T11:20:00Z", + cost_usd: 3, + }), +]; + +interface TaskRow { + readonly task?: string; + readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; +} + +interface Envelope { + readonly by_task: readonly TaskRow[]; +} + +function taskRowOfCost(envelope: Envelope, costUsd: number): TaskRow | undefined { + return envelope.by_task.find((row) => row.totals.cost_micro_usd === costUsd * 1_000_000); +} + +describe("aidd telemetry report — sessions from every worktree of one clone, before and after removal", () => { + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + }); + + async function seed(): Promise<{ projectDir: string; worktreeDir: string; fakeHome: string }> { + const env = await createTestEnv("telemetry-worktree-journal"); + cleanup = env.cleanup; + await gitInit(env.projectDir); + git(env.projectDir, ["commit", "-q", "--allow-empty", "-m", "seed"]); + await writeFile(join(env.projectDir, ".git", "info", "exclude"), ".aidd/\naidd_docs/\n"); + const worktreeDir = join(env.tempDir, WORKTREE_NAME); + git(env.projectDir, ["worktree", "add", "-q", "-b", WORKTREE_NAME, worktreeDir]); + for (const checkout of [env.projectDir, worktreeDir]) { + await mkdir(join(checkout, ".aidd"), { recursive: true }); + await writeFile( + join(checkout, ".aidd", "config.json"), + JSON.stringify({ telemetry: { enabled: true } }) + ); + } + const commonRuns = join(env.projectDir, ".git", "aidd", "runs"); + await mkdir(commonRuns, { recursive: true }); + await writeFile( + join(commonRuns, `${RUN_A}__${VENDOR_A}.jsonl`), + journal(RUN_A, VENDOR_A, ALPHA_TASK, "2026-02-10T09:00:00Z") + ); + const legacyRuns = join(worktreeDir, "aidd_docs", "runs"); + await mkdir(legacyRuns, { recursive: true }); + await writeFile( + join(legacyRuns, `${RUN_B}__${VENDOR_B}.jsonl`), + journal(RUN_B, VENDOR_B, BETA_TASK, "2026-02-10T11:00:00Z") + ); + const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); + await mkdir(sinkDir, { recursive: true }); + await writeFile( + join(sinkDir, "2026-02-28.jsonl"), + `${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n` + ); + return { projectDir: env.projectDir, worktreeDir, fakeHome: env.fakeHome }; + } + + async function report(cwd: string, fakeHome: string): Promise { + const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], cwd, fakeHome); + expect(result.exitCode, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Envelope; + } + + it("counts a session journalled under the common git directory and one still in a live worktree, from the main checkout", async () => { + const { projectDir, fakeHome } = await seed(); + + const envelope = await report(projectDir, fakeHome); + + expect(taskRowOfCost(envelope, 2)?.task).toBe(ALPHA_TASK); + expect(taskRowOfCost(envelope, 3)?.task).toBe(BETA_TASK); + }); + + it("counts the same sessions when the report runs from the linked worktree", async () => { + const { worktreeDir, fakeHome } = await seed(); + + const envelope = await report(worktreeDir, fakeHome); + + expect(taskRowOfCost(envelope, 2)?.task).toBe(ALPHA_TASK); + expect(taskRowOfCost(envelope, 3)?.task).toBe(BETA_TASK); + }); + + it("keeps the common-dir session once the worktree is removed, and loses only the pre-move journal that lived in it", async () => { + const { projectDir, worktreeDir, fakeHome } = await seed(); + git(projectDir, ["worktree", "remove", "--force", worktreeDir]); + + const envelope = await report(projectDir, fakeHome); + + expect(taskRowOfCost(envelope, 2)?.task).toBe(ALPHA_TASK); + const lost = taskRowOfCost(envelope, 3); + expect(lost).toBeDefined(); + expect(lost?.task).toBeUndefined(); + }); +}); + +describe("aidd telemetry report — a worktree session caught up from the tool's own transcript", () => { + const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; + const TASK = "2026_08/2026_08_21_probe-task"; + const HOOK = join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); + const HOOK_FIXTURES = join(REPOSITORY_ROOT, "scripts", "__tests__", "fixtures"); + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + }); + + async function fixture(name: string): Promise> { + return JSON.parse(await readFile(join(HOOK_FIXTURES, `${name}.json`), "utf8")); + } + + it("attributes the session to its task from the main checkout, with the sink empty and the worktree removed", async () => { + const env = await createTestEnv("telemetry-worktree-catch-up"); + cleanup = env.cleanup; + await gitInit(env.projectDir); + git(env.projectDir, ["commit", "-q", "--allow-empty", "-m", "seed"]); + await writeFile(join(env.projectDir, ".git", "info", "exclude"), ".aidd/\naidd_docs/\n"); + const worktreeDir = join(env.tempDir, WORKTREE_NAME); + git(env.projectDir, ["worktree", "add", "-q", "-b", WORKTREE_NAME, worktreeDir]); + for (const checkout of [env.projectDir, worktreeDir]) { + await mkdir(join(checkout, ".aidd"), { recursive: true }); + await writeFile( + join(checkout, ".aidd", "config.json"), + JSON.stringify({ telemetry: { enabled: true } }) + ); + } + await cp(join(process.cwd(), "tests", "fixtures", "local-cost"), env.fakeHome, { + recursive: true, + }); + const transcript = `${env.fakeHome}/.claude/projects/fake-project/${CLAUDE_SESSION}.jsonl`; + const session = { session_id: CLAUDE_SESSION, transcript_path: transcript, cwd: worktreeDir }; + const replay = (payload: Record, event: string): void => { + execFileSync("node", [HOOK, event], { + input: JSON.stringify({ ...payload, ...session }), + cwd: worktreeDir, + env: environmentWithoutGitVariables(process.env), + }); + }; + replay(await fixture("claude-code-session-start"), "session-start"); + const notes = join(worktreeDir, "aidd_docs", "tasks", ...TASK.split("/"), "notes.md"); + await mkdir(join(notes, ".."), { recursive: true }); + await writeFile(notes, "probe\n"); + replay( + { ...(await fixture("claude-code-post-tool-use-write")), tool_input: { file_path: notes } }, + "tool-used" + ); + git(env.projectDir, ["worktree", "remove", worktreeDir]); + + const result = await runCli( + ["telemetry", "report", "--days", "3650", "--task", TASK], + env.projectDir, + env.fakeHome + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(`task ${TASK}`); + expect(result.stdout).toMatch(/sessions\s+1\s/u); + expect(result.stdout).toMatch(/inferred from a written file\s+100%/u); + }); +}); diff --git a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts index f0478fbb8..1b3420769 100644 --- a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts +++ b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts @@ -16,8 +16,14 @@ export class InMemoryRunJournalReader implements RunJournalStore { readonly deletedFromDirs: string[] = []; readonly undeletable = new Set(); listCalls = 0; + legacyRunsDirs: readonly string[] = []; + readonly legacyRunFileNames = new Map(); private readonly journals = new Map(); + async listRunFilesIn(dir: string): Promise { + return dir === this.runsDir ? this.runFileNames : (this.legacyRunFileNames.get(dir) ?? []); + } + set(sessionId: string, journal: RunJournal): void { this.journals.set(sessionId, journal); } @@ -42,7 +48,14 @@ export class InMemoryRunJournalReader implements RunJournalStore { async deleteRunFile(dir: string, fileName: string): Promise { if (this.undeletable.has(fileName)) throw new Error(`cannot delete ${fileName}`); this.deletedFromDirs.push(dir); - this.runFileNames = this.runFileNames.filter((name) => name !== fileName); + if (dir === this.runsDir) { + this.runFileNames = this.runFileNames.filter((name) => name !== fileName); + } else { + const remaining = (this.legacyRunFileNames.get(dir) ?? []).filter( + (name) => name !== fileName + ); + this.legacyRunFileNames.set(dir, remaining); + } this.deletedFiles.push(fileName); } } @@ -51,6 +64,8 @@ export class InMemoryRunJournalReader implements RunJournalStore { * a session with telemetry enabled but no journal beside it would read. */ export const NULL_RUN_JOURNAL_READER: RunJournalStore = { runsDir: "/fake/project/aidd_docs/runs", + legacyRunsDirs: [], + listRunFilesIn: async () => [], read: async () => null, list: async () => [], listRunFiles: async () => [], diff --git a/cli/tests/helpers/telemetry-journal-hook.ts b/cli/tests/helpers/telemetry-journal-hook.ts index 2c3c75d3c..c4cc3b033 100644 --- a/cli/tests/helpers/telemetry-journal-hook.ts +++ b/cli/tests/helpers/telemetry-journal-hook.ts @@ -17,6 +17,8 @@ interface JournalRepoModule { deriveProjectId(repoRoot: string): string; telemetryEnabled(repoRoot: string): boolean; personRefusesTelemetry(): boolean; + /** `null` when telemetry is off or the directory is outside a checkout. */ + resolveRunsDir(cwd: string): { readonly dir: string; readonly repoRoot: string } | null; } export const journalRepo: JournalRepoModule = createRequire(import.meta.url)(hookLib("repo.cjs")); diff --git a/cli/tests/integration/run-journal-location-agrees.integration.test.ts b/cli/tests/integration/run-journal-location-agrees.integration.test.ts new file mode 100644 index 000000000..43a478749 --- /dev/null +++ b/cli/tests/integration/run-journal-location-agrees.integration.test.ts @@ -0,0 +1,77 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolvedRunsDir, samePath } from "../../src/kernel/paths.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { journalRepo } from "../helpers/telemetry-journal-hook.js"; + +const gitEnv = environmentWithoutGitVariables(process.env); + +function git(cwd: string, args: readonly string[]): void { + execFileSync("git", args, { cwd, env: gitEnv }); +} + +function switchOn(checkout: string): void { + mkdirSync(join(checkout, ".aidd"), { recursive: true }); + writeFileSync(join(checkout, ".aidd", "config.json"), '{"telemetry":{"enabled":true}}'); +} + +// Only the existing common dir is canonicalised; the last two segments stay as each side spelled them. +function canon(runsDir: string): string { + const parent = dirname(runsDir); + return join(realpathSync.native(dirname(parent)), basename(parent), basename(runsDir)); +} + +/** The hook asks `git rev-parse`, the CLI reads files: diverged, a report silently reads an + * empty directory while the hook writes another. */ +describe("the hook and the CLI resolve the run journal to one directory", () => { + let root: string; + const checkouts: Record = {}; + + beforeEach(() => { + delete process.env.AIDD_RUNS_DIR; + delete process.env.AIDD_TELEMETRY; + root = mkdtempSync(join(tmpdir(), "aidd-runs-agree-")); + const r = join(root, "r"); + mkdirSync(r); + git(r, ["init", "-q"]); + git(r, ["-c", "user.email=t@e", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed"]); + git(r, ["worktree", "add", "-q", join(root, "w")]); + git(root, ["clone", "-q", "--bare", r, join(root, "b.git")]); + git(join(root, "b.git"), ["worktree", "add", "-q", join(root, "bw")]); + mkdirSync(join(r, "sub")); + for (const checkout of [r, join(root, "w"), join(root, "bw")]) switchOn(checkout); + checkouts.main = r; + checkouts.sub = join(r, "sub"); + checkouts.worktree = join(root, "w"); + checkouts.bare = join(root, "bw"); + }); + + afterEach(() => { + delete process.env.AIDD_RUNS_DIR; + rmSync(root, { recursive: true, force: true }); + }); + + it.each([ + ["the main checkout", "main"], + ["a subdirectory", "sub"], + ["a linked worktree", "worktree"], + ["a bare clone's worktree", "bare"], + ])("agrees from %s", (_shape, key) => { + const cwd = checkouts[key] as string; + const hook = journalRepo.resolveRunsDir(cwd); + + expect(hook).not.toBeNull(); + expect(samePath(canon(hook?.dir ?? ""), canon(resolvedRunsDir(cwd)))).toBe(true); + }); + + it("agrees that AIDD_RUNS_DIR wins", () => { + const override = join(root, "override"); + process.env.AIDD_RUNS_DIR = override; + + expect(journalRepo.resolveRunsDir(checkouts.main as string)?.dir).toBe(override); + expect(resolvedRunsDir(checkouts.main as string)).toBe(override); + }); +}); diff --git a/cli/tests/kernel/paths.unit.test.ts b/cli/tests/kernel/paths.unit.test.ts index 6911a0bf6..7a2513794 100644 --- a/cli/tests/kernel/paths.unit.test.ts +++ b/cli/tests/kernel/paths.unit.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { + legacyRunsDirs, marketplaceCacheDir, parseBuiltMarketplaceDir, parseBuiltMarketplaceDirAtAnyRoot, parseUserBuiltMarketplaceDir, + resolvedRunsDir, samePath, samePathSegment, } from "../../src/kernel/paths.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; describe("marketplaceCacheDir()", () => { it("nests the marketplace under the project's cache", () => { @@ -88,3 +95,107 @@ describe("samePath()", () => { expect(samePath("/odd/a", "/odd\\a", "linux")).toBe(false); }); }); + +const gitEnv = environmentWithoutGitVariables(process.env); +const tempRoots: string[] = []; + +function git(cwd: string, args: readonly string[]): void { + execFileSync("git", args, { cwd, env: gitEnv }); +} + +function makeClone(): { r: string; w: string } { + // Canonical, as git records it: a runner's tmpdir can be an 8.3 alias of the same directory. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "aidd-common-dir-"))); + tempRoots.push(root); + const r = join(root, "r"); + const w = join(root, "w"); + mkdirSync(r); + git(r, ["init", "-q"]); + git(r, ["-c", "user.email=t@e", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed"]); + git(r, ["worktree", "add", "-q", w]); + return { r, w }; +} + +function runsUnderGit(repo: string): string { + return join(realpathSync.native(join(repo, ".git")), "aidd", "runs"); +} + +afterEach(() => { + delete process.env.AIDD_RUNS_DIR; + while (tempRoots.length > 0) { + rmSync(tempRoots.pop() ?? "", { recursive: true, force: true }); + } +}); + +describe("resolvedRunsDir()", () => { + it("answers /aidd/runs from the main checkout and from a linked worktree", () => { + const { r, w } = makeClone(); + const expected = runsUnderGit(r); + + expect(samePath(resolvedRunsDir(r), expected)).toBe(true); + expect(samePath(resolvedRunsDir(w), expected)).toBe(true); + }); + + it("answers /aidd_docs/runs outside any checkout", () => { + const outside = mkdtempSync(join(tmpdir(), "aidd-common-dir-")); + tempRoots.push(outside); + + expect(samePath(resolvedRunsDir(outside), join(outside, "aidd_docs", "runs"))).toBe(true); + }); + + it("answers AIDD_RUNS_DIR outright, and then legacyRunsDirs is empty", () => { + const { r } = makeClone(); + process.env.AIDD_RUNS_DIR = "/custom/runs"; + + expect(resolvedRunsDir(r)).toBe("/custom/runs"); + expect(legacyRunsDirs(r)).toEqual([]); + }); +}); + +describe("legacyRunsDirs()", () => { + it("lists the current checkout first, then the other live one", () => { + const { r, w } = makeClone(); + mkdirSync(join(r, "aidd_docs", "runs"), { recursive: true }); + mkdirSync(join(w, "aidd_docs", "runs"), { recursive: true }); + + const fromMain = legacyRunsDirs(r); + const fromWorktree = legacyRunsDirs(w); + + expect(fromMain).toHaveLength(2); + expect(samePath(fromMain[0] ?? "", join(r, "aidd_docs", "runs"))).toBe(true); + expect(samePath(fromMain[1] ?? "", join(w, "aidd_docs", "runs"))).toBe(true); + expect(fromWorktree).toHaveLength(2); + expect(samePath(fromWorktree[0] ?? "", join(w, "aidd_docs", "runs"))).toBe(true); + expect(samePath(fromWorktree[1] ?? "", join(r, "aidd_docs", "runs"))).toBe(true); + }); + + it("lists a worktree once when it is reached through a link to its real path", () => { + const { r, w } = makeClone(); + mkdirSync(join(r, "aidd_docs", "runs"), { recursive: true }); + mkdirSync(join(w, "aidd_docs", "runs"), { recursive: true }); + const link = `${dirname(r)}-link`; + symlinkSync(dirname(r), link, "junction"); + tempRoots.push(link); + + const listed = legacyRunsDirs(join(link, "w")); + + expect(listed).toHaveLength(2); + }); + + it("lists only the directories that exist", () => { + const { r, w } = makeClone(); + mkdirSync(join(w, "aidd_docs", "runs"), { recursive: true }); + + const listed = legacyRunsDirs(r); + + expect(listed).toHaveLength(1); + expect(samePath(listed[0] ?? "", join(w, "aidd_docs", "runs"))).toBe(true); + }); + + it("is empty outside any checkout", () => { + const outside = mkdtempSync(join(tmpdir(), "aidd-common-dir-")); + tempRoots.push(outside); + + expect(legacyRunsDirs(outside)).toEqual([]); + }); +}); diff --git a/cli/tests/kernel/reading/git-common-dir.unit.test.ts b/cli/tests/kernel/reading/git-common-dir.unit.test.ts new file mode 100644 index 000000000..46960a366 --- /dev/null +++ b/cli/tests/kernel/reading/git-common-dir.unit.test.ts @@ -0,0 +1,113 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { samePath } from "../../../src/kernel/paths.js"; +import { gitCommonDirAbove, worktreeRootsOf } from "../../../src/kernel/reading/git-common-dir.js"; +import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-environment.js"; + +const gitEnv = environmentWithoutGitVariables(process.env); +const roots: string[] = []; + +function git(cwd: string, args: readonly string[]): void { + execFileSync("git", args, { cwd, env: gitEnv }); +} + +/** A committed repo, one linked worktree, and a bare clone of it with its own worktree. */ +function makeClone(): { root: string; r: string; w: string; bare: string; bw: string } { + const root = mkdtempSync(join(tmpdir(), "aidd-common-dir-")); + roots.push(root); + const r = join(root, "r"); + const w = join(root, "w"); + const bare = join(root, "b.git"); + const bw = join(root, "bw"); + mkdirSync(r); + git(r, ["init", "-q"]); + git(r, ["-c", "user.email=t@e", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed"]); + git(r, ["worktree", "add", "-q", w]); + git(root, ["clone", "-q", "--bare", r, bare]); + git(bare, ["worktree", "add", "-q", bw]); + return { root, r, w, bare, bw }; +} + +function canon(path: string): string { + return realpathSync.native(path); +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop() ?? "", { recursive: true, force: true }); + } +}); + +describe("gitCommonDirAbove()", () => { + it("names the main checkout's .git from the checkout and from a subdirectory", () => { + const { r } = makeClone(); + const nested = join(r, "a", "b"); + mkdirSync(nested, { recursive: true }); + + expect(samePath(canon(gitCommonDirAbove(r) ?? ""), canon(join(r, ".git")))).toBe(true); + expect(samePath(canon(gitCommonDirAbove(nested) ?? ""), canon(join(r, ".git")))).toBe(true); + }); + + it("names the main checkout's .git from a linked worktree", () => { + const { r, w } = makeClone(); + + expect(samePath(canon(gitCommonDirAbove(w) ?? ""), canon(join(r, ".git")))).toBe(true); + }); + + it("names the bare directory from a bare clone's worktree", () => { + const { bare, bw } = makeClone(); + + expect(samePath(canon(gitCommonDirAbove(bw) ?? ""), canon(bare))).toBe(true); + }); + + it("answers the git dir itself when the pointer's commondir cannot be read", () => { + const dir = mkdtempSync(join(tmpdir(), "aidd-common-dir-")); + roots.push(dir); + writeFileSync(join(dir, ".git"), "gitdir: /does/not/exist/.git/worktrees/x\n"); + + expect( + samePath(gitCommonDirAbove(dir) ?? "", resolve(dir, "/does/not/exist/.git/worktrees/x")) + ).toBe(true); + }); + + it("answers null outside any checkout", () => { + const dir = mkdtempSync(join(tmpdir(), "aidd-common-dir-")); + roots.push(dir); + + expect(gitCommonDirAbove(dir)).toBeNull(); + }); +}); + +describe("worktreeRootsOf()", () => { + it("lists the main root first, then each live linked worktree", () => { + const { r, w } = makeClone(); + + const rootsOf = worktreeRootsOf(join(r, ".git")); + + expect(rootsOf).toHaveLength(2); + expect(samePath(canon(rootsOf[0] ?? ""), canon(r))).toBe(true); + expect(samePath(canon(rootsOf[1] ?? ""), canon(w))).toBe(true); + }); + + it("skips a worktree deleted from disk and still registered", () => { + const { r, w } = makeClone(); + rmSync(w, { recursive: true, force: true }); + + const rootsOf = worktreeRootsOf(join(r, ".git")); + + expect(rootsOf).toHaveLength(1); + expect(samePath(canon(rootsOf[0] ?? ""), canon(r))).toBe(true); + }); + + it("lists no main root for a bare clone", () => { + const { bare, bw } = makeClone(); + + const rootsOf = worktreeRootsOf(bare); + + expect(rootsOf).toHaveLength(1); + expect(samePath(canon(rootsOf[0] ?? ""), canon(bw))).toBe(true); + }); +}); diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index 5c7b51946..cee1c1b93 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -72,6 +72,7 @@ function emptyReport() { const NOTHING_TO_REMOVE: TelemetryRemovalPreview = { journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, + legacyJournals: [], sink: { scope: "machine", path: "/records", dayFileNames: [] }, identity: { scope: "machine", path: "/h/identity.json", present: false, unreadable: false }, history: { certainty: "none" }, @@ -104,6 +105,8 @@ beforeEach(() => { forgetPreview.mockResolvedValue(NOTHING_TO_REMOVE); forgetRemove.mockResolvedValue({ journal: { removed: 0, failed: [] }, + journalPath: "/repo/aidd_docs/runs", + legacyJournals: [], sink: { removed: 0, failed: [] }, identity: { removed: 0, failed: [] }, history: { certainty: "none" }, diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts index f9868f2eb..3459d488b 100644 --- a/cli/tests/presentation/display/telemetry-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry-display.unit.test.ts @@ -434,7 +434,8 @@ describe("printTelemetryOffReport", () => { ) ).toEqual([ "AIDD telemetry: off (/p/.aidd/config.json)", - "This stops new recording only — sessions already journalled stay in aidd_docs/runs/ " + + "This stops new recording only — sessions already journalled stay under the clone's git " + + "directory (or an earlier version's aidd_docs/runs/) " + "and whatever `aidd telemetry read` already stored, and `aidd telemetry report` still " + "reports them. Run `aidd telemetry forget` to remove what was already measured.", ]); diff --git a/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts b/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts index 897af2051..7eaf67bed 100644 --- a/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { TelemetryRemovalPreview } from "../../../src/contexts/telemetry/domain/telemetry-removal.js"; import { @@ -10,6 +11,7 @@ import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; function preview(overrides: Partial = {}): TelemetryRemovalPreview { return { journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, + legacyJournals: [], sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, identity: { scope: "machine", @@ -70,6 +72,26 @@ describe("printTelemetryForgetPreview", () => { ]); }); + it("lists each earlier journal between the project journal and the machine records", () => { + expect( + printedPreview({ + journal: ONE_RUN_FILE, + legacyJournals: [ + { scope: "project", path: "/wt/aidd_docs/runs", runFileNames: ["a.jsonl", "b.jsonl"] }, + ], + }) + ).toEqual([ + "This would remove:", + " This project's run journal (/repo/aidd_docs/runs): 1 run file(s)", + " An earlier run journal, from before it moved under the git directory " + + "(/wt/aidd_docs/runs): 2 run file(s)", + " This machine's stored records — every project measured on this machine " + + "(/home/.config/aidd/telemetry): 0 day file(s)", + " This machine's identity (/home/.config/aidd/identity.json): nothing to remove", + NO_REPOSITORY, + ]); + }); + it("counts a present identity as one file", () => { expect( printedPreview({ @@ -176,6 +198,8 @@ describe("printTelemetryForgetResult", () => { printTelemetryForgetResult(output, { journal: { removed: 3, failed: [] }, + journalPath: "/repo/aidd_docs/runs", + legacyJournals: [], sink: { removed: 2, failed: [] }, identity: { removed: 1, failed: [] }, history: { certainty: "none" }, @@ -198,6 +222,8 @@ describe("printTelemetryForgetResult", () => { printTelemetryForgetResult(output, { journal: { removed: 1, failed: [{ path: "b.jsonl", reason: "EACCES" }] }, + journalPath: "/repo/aidd_docs/runs", + legacyJournals: [], sink: { removed: 0, failed: [{ path: "2026-03-02.jsonl", reason: "EPERM" }] }, identity: { removed: 0, failed: [{ path: "identity.json", reason: "EBUSY" }] }, history: { certainty: "none" }, @@ -207,17 +233,46 @@ describe("printTelemetryForgetResult", () => { " This project's run journal: 1 removed, 1 could not be removed", " This machine's stored records: 0 removed, 1 could not be removed", " This machine's identity: 0 removed, 1 could not be removed", - "Could not remove journal run file b.jsonl — EACCES", + `Could not remove journal run file ${join("/repo/aidd_docs/runs", "b.jsonl")} — EACCES`, "Could not remove sink day file 2026-03-02.jsonl — EPERM", "Could not remove identity file identity.json — EBUSY", ]); }); + it("reports each earlier journal on its own line, and names the directory of a file it could not remove", () => { + const output = new CapturingOutput(); + + printTelemetryForgetResult(output, { + journal: { removed: 1, failed: [] }, + journalPath: "/repo/aidd_docs/runs", + legacyJournals: [ + { + path: "/wt/aidd_docs/runs", + outcome: { removed: 1, failed: [{ path: "x.jsonl", reason: "EACCES" }] }, + }, + ], + sink: { removed: 0, failed: [] }, + identity: { removed: 0, failed: [] }, + history: { certainty: "none" }, + }); + + expect(output.lines.slice(1, 6)).toEqual([ + " This project's run journal: 1 removed", + " An earlier run journal, from before it moved under the git directory " + + "(/wt/aidd_docs/runs): 1 removed, 1 could not be removed", + " This machine's stored records: 0 removed", + " This machine's identity: 0 removed", + `Could not remove earlier journal run file ${join("/wt/aidd_docs/runs", "x.jsonl")} — EACCES`, + ]); + }); + it("says only what was removed when nothing failed", () => { const output = new CapturingOutput(); printTelemetryForgetResult(output, { journal: { removed: 0, failed: [] }, + journalPath: "/repo/aidd_docs/runs", + legacyJournals: [], sink: { removed: 0, failed: [] }, identity: { removed: 0, failed: [] }, history: { certainty: "none" }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abe4c6e43..df428a217 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,7 +81,7 @@ Place each capability in its owning concern and delegate to it. | Concern | Contract | | --- | --- | | Consent | Committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. | -| Writing | Git-ignored, append-only session observations. | +| Writing | Append-only session observations, one directory per clone under its common git directory, outside every working tree. | | Reading | Derive task identity and join provider measurements. | See the [journal contract](../aidd_docs/runs/README.md). diff --git a/docs/FAQ.md b/docs/FAQ.md index f7e4ae3e8..aadd12577 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -59,7 +59,7 @@ installed. | Where | What | | --- | --- | -| `aidd_docs/runs/` in your repository, git-ignored | which session served which task, which skill was running when, and which files inside a task folder changed | +| `.git/aidd/runs/` in your clone, outside every working tree | which session served which task, which skill was running when, and which files inside a task folder changed | | `~/.config/aidd/telemetry/` | token counts and model names, read out of the transcript your AI tool already wrote | **No prompt, no code, no diff** — the stored shape is an allowlist, field by field, in diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index f0f351db7..5b2ed7db4 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -17,6 +17,11 @@ Enable measurement, work in a session, then report and verify. Use skills or CLI skills stop and explain when `aidd` cannot answer. Report after the turn: hooks fire before its tokens are durably written. +**Update `aidd` before this plugin.** This plugin journals under the clone's common git +directory; an `aidd` released before that move reads only `aidd_docs/runs/`, so with a newer +plugin it reports no new session and `aidd telemetry forget` misses them. A newer `aidd` still +reads journals an older plugin wrote, so updating the CLI first is always safe. + | Ask your tool for | It runs | You get | | --- | --- | --- | | `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | @@ -80,8 +85,9 @@ Usage groups: step, model, task, flow, tool and person. Attribution states its e ### Stored data Recording is local and opt-in, with no export, prompts, code or diffs. Hooks append one -line per observation to git-ignored `aidd_docs/runs/__.jsonl`, never -rewriting it or recording tokens, cost or model. Joined measurement records are stored under +line per observation to `/aidd/runs/__.jsonl`, one +directory per clone that every worktree shares and no commit can reach, never rewriting it +or recording tokens, cost or model. Joined measurement records are stored under `~/.config/aidd/telemetry/` according to [the record contract](../../aidd_docs/product/metrics-contract.md). ### Privacy controls diff --git a/plugins/aidd-telemetry/hooks/lib/repo.cjs b/plugins/aidd-telemetry/hooks/lib/repo.cjs index 1951fcf48..4148790cc 100644 --- a/plugins/aidd-telemetry/hooks/lib/repo.cjs +++ b/plugins/aidd-telemetry/hooks/lib/repo.cjs @@ -200,9 +200,16 @@ function deriveProjectId(repoRoot) { return projectIdFromRemote(repoRoot, getRemoteUrl(repoRoot)); } -// `AIDD_RUNS_DIR` overrides outright. The directory existing is not a second gate. -function runsDir(repoRoot) { - return process.env.AIDD_RUNS_DIR || path.join(repoRoot, "aidd_docs", "runs"); +// `AIDD_RUNS_DIR` overrides outright. Otherwise the clone's common git directory: shared by +// every worktree of one clone, outside every working tree, and never touched by +// `git worktree remove`. Without a common dir, the checkout's own root, as before. +// The directory existing is not a second gate. +const RUNS_UNDER_GIT_DIR = ["aidd", "runs"]; + +function runsDir(repoRoot, commonDir) { + if (process.env.AIDD_RUNS_DIR) return process.env.AIDD_RUNS_DIR; + if (typeof commonDir === "string" && commonDir) return path.join(commonDir, ...RUNS_UNDER_GIT_DIR); + return path.join(repoRoot, "aidd_docs", "runs"); } // What this hook writes is who-worked-on-what-for-how-long, so it is not left world-readable. @@ -250,17 +257,18 @@ function restrictToCurrentUser(target, { inheritable = false } = {}) { } } -// A decision, not an inherited default: a worktree keeps its own journal, at the worktree's -// own root and never at `--git-common-dir`'s shared repository. -// -// A bare clone plus worktrees has no main working tree to write into at all, and even where -// one exists, writing into it from another worktree dirties a checkout on a different branch -// whose `.gitignore` was never asked to carry the entry. Cross-worktree joining is served by -// `worktreeFields` naming the worktree on `session_start` instead. +// A decision, not an inherited default: one journal per clone, under the common git +// directory, never at a worktree's root. A worktree's root is deleted by +// `git worktree remove` with an ignored journal inside it, which lost every session an +// agent ran there. The earlier reasons still hold and are what rules out the main working +// tree: a bare clone has none, and writing into it from another worktree dirties a +// checkout on a different branch. `worktreeFields` still names the worktree on +// `session_start`, so the shared journal keeps sessions apart. +// Recorded in aidd_docs/memory/internal/decisions/one-run-journal-per-clone.md. function resolveRunsDir(cwd) { const location = getRepoLocation(cwd); if (!location || !telemetryEnabled(location.repoRoot)) return null; - return { ...location, dir: runsDir(location.repoRoot) }; + return { ...location, dir: runsDir(location.repoRoot, location.gitDir) }; } // A token-authenticated clone leaves a live credential in the remote's userinfo, and the diff --git a/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md b/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md index 3f1a27f56..cfb9727e3 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md @@ -12,10 +12,10 @@ A confirmed `aidd` command, from check. ## Process -1. **Say what it records, before asking.** It writes into `aidd_docs/runs/` which session served which task and which skill was running when. It records no prompt, no code, no diff, and no person's name or address. Nothing leaves the machine. Also say that `.aidd/config.json` — the file this is about to write — is git-tracked, so this turns measurement on for everyone who clones this repository, not only for this person. +1. **Say what it records, before asking.** It writes, under this clone's git directory and outside every working tree, which session served which task and which skill was running when. It records no prompt, no code, no diff, and no person's name or address. Nothing leaves the machine. Also say that `.aidd/config.json` — the file this is about to write — is git-tracked, so this turns measurement on for everyone who clones this repository, not only for this person. 2. **Ask.** Wait for a yes. - The user declines: stop, and write nothing. -3. **Turn it on.** Run `aidd telemetry on --yes` — the `--yes` confirms the consequence just explained, which merges into whatever the config already holds. The same run also adds `aidd_docs/runs/` to `.gitignore` — the journal belongs to this repository and is never offered to a commit. +3. **Turn it on.** Run `aidd telemetry on --yes` — the `--yes` confirms the consequence just explained, which merges into whatever the config already holds. The same run also adds `aidd_docs/runs/` to `.gitignore`, which keeps journals an earlier version wrote there out of a commit; new ones never land in the working tree. 4. **Relay what it prints about existing history.** If `aidd telemetry on --yes` names files under `aidd_docs/runs/` already tracked by git, say so plainly: the repository, the task folders written into, the skills run, their timings, and every file each session wrote is in that history now — no person's name or address. Nothing is removed or rewritten — what to do about it is the user's call, not this skill's. 5. **Say what it cannot recover.** The journal starts now, so sessions that already ran carry no step and no task and will read as unattributed. 6. **Say it is reversible, and what reversing keeps.** `aidd telemetry off` stops the recording from that moment; sessions already measured stay measured and still report. diff --git a/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md b/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md index 5042fa84b..ef85cabee 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md @@ -12,7 +12,7 @@ Evidence that a session is recorded, or a named reason why none is. ## Process -1. **Look for a run file.** Run `ls aidd_docs/runs/*.jsonl`. +1. **Look for a run file.** Run `ls "$(git rev-parse --git-common-dir)/aidd/runs"` (it works in bash and PowerShell alike), or list the directory `AIDD_RUNS_DIR` names when it is set. - None, and the switch was just turned on: expected, since the hook writes at the next session start. Ask for a new session and stop, without reporting a failure. - None, and the switch has been on a while: the hook is not running, which is the host tool failing to register the plugin's hooks rather than a measurement problem. 2. **Read one back.** A run file's first line names the tool, the project and the session, and its `step_start` lines name the skills that have run. diff --git a/scripts/__tests__/aidd-telemetry-file-writes.test.js b/scripts/__tests__/aidd-telemetry-file-writes.test.js index a546bdeda..9cf5e9b2d 100644 --- a/scripts/__tests__/aidd-telemetry-file-writes.test.js +++ b/scripts/__tests__/aidd-telemetry-file-writes.test.js @@ -101,7 +101,7 @@ test("tells the run file what it skipped, rather than reading as complete covera const { buildSessionStartLine, appendLine, runFileName, generateUlid } = require(RECORD); const { handleTaskFilesObserved, MAX_SCAN_ENTRIES } = require(FILE_WRITES); - const runsDir = path.join(repo, "aidd_docs", "runs"); + const runsDir = path.join(repo, ".git", "aidd", "runs"); fs.mkdirSync(runsDir, { recursive: true }); const runId = generateUlid(); const vendorId = "wide-tree-session"; @@ -192,7 +192,7 @@ test("records the write a host stated when that host names its workspace rather const vendorId = "workspace-host-session"; const runFile = withWorkspaceHost(({ fileWrites, record }) => { - const runsDir = path.join(repo, "aidd_docs", "runs"); + const runsDir = path.join(repo, ".git", "aidd", "runs"); fs.mkdirSync(runsDir, { recursive: true }); const runId = record.generateUlid(); const filePath = path.join(runsDir, record.runFileName(runId, vendorId)); diff --git a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js b/scripts/__tests__/aidd-telemetry-journal-perf-harness.js index 3eccd83ab..2b67ba671 100644 --- a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js +++ b/scripts/__tests__/aidd-telemetry-journal-perf-harness.js @@ -30,7 +30,7 @@ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, e execFileSync("git", ["config", "user.name", "Test"], { cwd: repo, env: CLEAN_ENV }); execFileSync("git", ["remote", "add", "origin", "git@github.com:acme/perf.git"], { cwd: repo, env: CLEAN_ENV }); -const dir = path.join(repo, "aidd_docs", "runs"); +const dir = path.join(repo, ".git", "aidd", "runs"); fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); diff --git a/scripts/__tests__/aidd-telemetry-journal.test.js b/scripts/__tests__/aidd-telemetry-journal.test.js index 67b6cbfc7..a1242c0a5 100644 --- a/scripts/__tests__/aidd-telemetry-journal.test.js +++ b/scripts/__tests__/aidd-telemetry-journal.test.js @@ -572,15 +572,17 @@ function withRunsDirEnv({ set = {}, unset = [] }, fn) { } } -test("runsDir defaults to /aidd_docs/runs when AIDD_RUNS_DIR is unset", () => { +test("runsDir defaults to /aidd/runs, and to /aidd_docs/runs only without a common dir, when AIDD_RUNS_DIR is unset", () => { withRunsDirEnv({ unset: ["AIDD_RUNS_DIR"] }, () => { + assert.equal(runsDir("/repo", "/repo/.git"), path.join("/repo/.git", "aidd", "runs")); + assert.equal(runsDir("/repo", ""), path.join("/repo", "aidd_docs", "runs")); assert.equal(runsDir("/repo"), path.join("/repo", "aidd_docs", "runs")); }); }); test("AIDD_RUNS_DIR overrides the in-repo default outright", () => { withRunsDirEnv({ set: { AIDD_RUNS_DIR: "/custom/runs" } }, () => { - assert.equal(runsDir("/repo"), "/custom/runs"); + assert.equal(runsDir("/repo", "/repo/.git"), "/custom/runs"); }); }); @@ -615,19 +617,23 @@ test("getRepoRoot resolves a worktree to itself, never to the repository it shar assert.notEqual(canonicalPath(worktreeRoot), canonicalPath(getRepoRoot(main))); }); -test("resolveRunsDir writes a worktree's journal under the worktree, not the main checkout", () => { +test("resolveRunsDir writes a worktree's journal under the clone's common git directory, shared with the main checkout", () => { const main = makeTempRepo(); const worktree = path.join(makeTempDir("aidd-telemetry-worktree-"), "wt"); addWorktree(main, worktree); writeTelemetryConfig(worktree, { enabled: true }); - fs.mkdirSync(runsDirOf(worktree), { recursive: true }); + fs.mkdirSync(legacyRunsDirOf(worktree), { recursive: true }); const target = resolveRunsDir(worktree); assert.ok(target, "resolveRunsDir must resolve inside a worktree"); assert.equal(canonicalPath(target.repoRoot), canonicalPath(worktree)); - assert.equal(canonicalPath(target.dir), canonicalPath(runsDirOf(worktree))); - assert.notEqual(canonicalPath(target.dir), canonicalPath(runsDirOf(main))); + assert.equal(path.basename(target.dir), "runs"); + assert.equal(path.basename(path.dirname(target.dir)), "aidd"); + assert.equal( + canonicalPath(path.dirname(path.dirname(target.dir))), + canonicalPath(path.join(main, ".git")), + ); }); function makeTempDir(prefix) { @@ -645,7 +651,7 @@ function makeTempRepo({ remote, withRunsDir = true, withConfig = true } = {}) { execFileSync("git", ["remote", "add", "origin", remote], { cwd: dir, env: CLEAN_ENV }); } if (withRunsDir) { - fs.mkdirSync(path.join(dir, "aidd_docs", "runs"), { recursive: true }); + fs.mkdirSync(legacyRunsDirOf(dir), { recursive: true }); } if (withConfig) { writeTelemetryConfig(dir, { enabled: true }); @@ -660,7 +666,20 @@ function writeTelemetryConfig(repo, { enabled = true, endpoint = "http://127.0.0 fs.writeFileSync(path.join(dir, "config.json"), content); } +// Where the hook writes: under the clone's common git directory, which every worktree of +// one clone shares. Asked of git rather than joined by hand, so a linked worktree resolves +// to its main repository's directory exactly as the hook does. function runsDirOf(repo) { + const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: repo, + encoding: "utf8", + env: CLEAN_ENV, + }).trim(); + return path.join(path.resolve(repo, commonDir), "aidd", "runs"); +} + +// The pre-move location. Still created by some fixtures to prove it is ignored, never written. +function legacyRunsDirOf(repo) { return path.join(repo, "aidd_docs", "runs"); } @@ -781,12 +800,13 @@ test("a session writes nothing and exits 0 when .aidd/config.json is absent, eve ); assert.equal(result.status, 0); assert.equal(readRunFiles(runsDirOf(repo)).length, 0); + assert.equal(fs.existsSync(runsDirOf(repo)), false); } finally { cleanup(repo); } }); -test("aidd_docs/runs/ is no longer a permission: a switched-on session creates it on demand when it does not exist yet", () => { +test("the run journal directory is no permission: a switched-on session creates it on demand when it does not exist yet", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/dir-on-demand.git", withRunsDir: false }); try { assert.equal(fs.existsSync(runsDirOf(repo)), false); @@ -965,7 +985,7 @@ test("AIDD_TELEMETRY unset, empty, or any other value is not a choice — never } }); -test("a session writes exactly one file directly under aidd_docs/runs/ when opted in, its session_start line carrying exactly the documented keys", () => { +test("a session writes exactly one file directly under the common git directory's aidd/runs/ when opted in, its session_start line carrying exactly the documented keys", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/opted-in.git" }); try { const sessionId = "00000000-0000-4000-8000-000000000002"; @@ -1426,7 +1446,7 @@ test("vendor_field names the export-side attribute, and vendor_id is the same se } }); -test("two repositories with different remotes each write into their own aidd_docs/runs/, keyed on the repository root rather than project_id", () => { +test("two repositories with different remotes each write into their own common git directory, keyed on the repository root rather than project_id", () => { const repoA = makeTempRepo({ remote: "git@github.com:acme/repo-a.git" }); const repoB = makeTempRepo({ remote: "git@github.com:acme/repo-b.git" }); try { @@ -2285,10 +2305,10 @@ test("in a real temporary git repo: the marker files are tracked, a record file const rules = readRunsGitignoreBlock(); fs.writeFileSync(path.join(repo, ".gitignore"), `${rules.join("\n")}\n`); - const runsPath = runsDirOf(repo); - fs.mkdirSync(runsPath, { recursive: true }); - fs.writeFileSync(path.join(runsPath, ".gitkeep"), ""); - fs.writeFileSync(path.join(runsPath, "README.md"), "marker\n"); + const markersPath = legacyRunsDirOf(repo); + fs.mkdirSync(markersPath, { recursive: true }); + fs.writeFileSync(path.join(markersPath, ".gitkeep"), ""); + fs.writeFileSync(path.join(markersPath, "README.md"), "marker\n"); execFileSync("git", ["add", "-A"], { cwd: repo, env: CLEAN_ENV }); execFileSync("git", ["commit", "-q", "-m", "opt into the run journal"], { cwd: repo, env: CLEAN_ENV }); @@ -2303,13 +2323,12 @@ test("in a real temporary git repo: the marker files are tracked, a record file const result = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); assert.equal(result.status, 0); - const recordFiles = fs.readdirSync(runsPath).filter((f) => f.endsWith(".jsonl")); - assert.equal(recordFiles.length, 1, "the record did not land in aidd_docs/runs/"); - const recordPath = path.join(runsPath, recordFiles[0]); + const recordFiles = readRunFiles(runsDirOf(repo)); + assert.equal(recordFiles.length, 1, "the record did not land under the common git directory"); + const recordPath = recordFiles[0]; assert.ok(fs.existsSync(recordPath), "the record must be present on disk"); - const checkIgnore = spawnSync("git", ["check-ignore", "-q", recordPath], { cwd: repo, env: CLEAN_ENV }); - assert.equal(checkIgnore.status, 0, "the record file must be recognised as git-ignored"); + assert.equal(fs.readdirSync(markersPath).filter((f) => f.endsWith(".jsonl")).length, 0, "no record may land in aidd_docs/runs/ any more"); execFileSync("git", ["add", "-A"], { cwd: repo, env: CLEAN_ENV }); const status = execFileSync("git", ["status", "--porcelain"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }); @@ -3500,14 +3519,14 @@ function makeWorktree(repo, name) { cwd: repo, env: CLEAN_ENV, }); - fs.mkdirSync(runsDirOf(worktree), { recursive: true }); + fs.mkdirSync(legacyRunsDirOf(worktree), { recursive: true }); writeTelemetryConfig(worktree, { enabled: true }); return worktree; } -function sessionStartLineIn(dir) { - const [file] = readRunFiles(runsDirOf(dir)); - assert.ok(file, `no run file was written under ${runsDirOf(dir)}`); +function sessionStartLineIn(dir, sessionId) { + const file = readRunFiles(runsDirOf(dir)).find((f) => path.basename(f).endsWith(`__${sessionId}.jsonl`)); + assert.ok(file, `no run file for ${sessionId} under ${runsDirOf(dir)}`); return readLines(file)[0]; } @@ -3521,7 +3540,7 @@ test("a session in a linked worktree names the worktree it ran in and the reposi ); }); - const line = sessionStartLineIn(worktree); + const line = sessionStartLineIn(worktree, "wt-named-session"); assert.equal(line.worktree_id, path.basename(worktree)); assert.equal(line.worktree_repo_id, path.basename(repo)); } finally { @@ -3539,8 +3558,8 @@ test("two sessions from two worktrees of one repository are distinguishable in t processPayload(makePayload({ cwd: second, sessionId: "wt-beta", event: "SessionStart" })); }); - const alpha = sessionStartLineIn(first); - const beta = sessionStartLineIn(second); + const alpha = sessionStartLineIn(first, "wt-alpha"); + const beta = sessionStartLineIn(second, "wt-beta"); assert.notEqual(alpha.worktree_id, beta.worktree_id); // Both worktrees carry the same repository. Without this field they would not: with no // remote, project_id falls back to each worktree's own directory name. @@ -3559,7 +3578,7 @@ test("a session in a plain checkout carries no worktree field at all - not null, ); }); - const line = sessionStartLineIn(repo); + const line = sessionStartLineIn(repo, "plain-checkout-session"); // An empty string would gather every plain checkout into one group as though they were // the same worktree; the exact key set is what proves neither key is there at all. assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); @@ -3570,6 +3589,59 @@ test("a session in a plain checkout carries no worktree field at all - not null, } }); +test("a session in a linked worktree writes into the main repository's .git/aidd/runs, never into the worktree", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-shared.git" }); + const worktree = makeWorktree(repo, "shared"); + try { + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: worktree, sessionId: "wt-shared", event: "SessionStart" })); + }); + const shared = readRunFiles(path.join(repo, ".git", "aidd", "runs")); + assert.equal(shared.filter((f) => path.basename(f).endsWith("__wt-shared.jsonl")).length, 1); + assert.equal(readRunFiles(legacyRunsDirOf(worktree)).length, 0, "nothing may land in the worktree's own aidd_docs/runs/"); + } finally { + cleanup(repo, worktree); + } +}); + +test("the main checkout and a linked worktree write into one directory", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-one-dir.git" }); + const worktree = makeWorktree(repo, "onedir"); + try { + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: repo, sessionId: "main-session", event: "SessionStart" })); + processPayload(makePayload({ cwd: worktree, sessionId: "wt-session", event: "SessionStart" })); + }); + assert.equal(canonicalPath(runsDirOf(repo)), canonicalPath(runsDirOf(worktree))); + const names = readRunFiles(runsDirOf(repo)).map((f) => path.basename(f)); + assert.ok(names.some((n) => n.endsWith("__main-session.jsonl"))); + assert.ok(names.some((n) => n.endsWith("__wt-session.jsonl"))); + } finally { + cleanup(repo, worktree); + } +}); + +test("git worktree remove, without --force, leaves the worktree's journal in place", () => { + const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-removed.git" }); + const worktree = makeWorktree(repo, "removed"); + try { + // makeWorktree leaves an untracked .aidd/config.json, which would make + // `git worktree remove` refuse. Ignore it the way a real project's .gitignore would; + // info/exclude lives in the common dir, so it applies to every worktree. + fs.mkdirSync(path.join(repo, ".git", "info"), { recursive: true }); + fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), "\n.aidd/\naidd_docs/\n"); + withEnv({ AIDD_RUNS_DIR: "" }, () => { + processPayload(makePayload({ cwd: worktree, sessionId: "wt-removed", event: "SessionStart" })); + }); + execFileSync("git", ["worktree", "remove", worktree], { cwd: repo, env: CLEAN_ENV }); + assert.equal(fs.existsSync(worktree), false, "the worktree itself must be gone"); + const names = readRunFiles(path.join(repo, ".git", "aidd", "runs")).map((f) => path.basename(f)); + assert.ok(names.some((n) => n.endsWith("__wt-removed.jsonl")), "the journal must outlive the worktree"); + } finally { + cleanup(repo); + } +}); + test("naming the worktree costs a plain checkout no extra git call - the two words are read off the rev-parse it already paid for", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-shellout.git" }); const worktree = makeWorktree(repo, "alpha"); @@ -3612,7 +3684,7 @@ test("a plain checkout that happens to live in a directory named 'worktrees' is ); }); - assert.deepEqual(Object.keys(sessionStartLineIn(repo)).sort(), SESSION_START_KEYS); + assert.deepEqual(Object.keys(sessionStartLineIn(repo, "worktrees-named-repo")).sort(), SESSION_START_KEYS); } finally { cleanup(parent); } diff --git a/scripts/__tests__/aidd-telemetry-plugin-version.test.js b/scripts/__tests__/aidd-telemetry-plugin-version.test.js index 567384d62..f14212b31 100644 --- a/scripts/__tests__/aidd-telemetry-plugin-version.test.js +++ b/scripts/__tests__/aidd-telemetry-plugin-version.test.js @@ -176,7 +176,7 @@ function makeTempRepo() { execFileSync("git", ["init", "-q"], { cwd: dir, env: CLEAN_ENV }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, env: CLEAN_ENV }); execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, env: CLEAN_ENV }); - fs.mkdirSync(path.join(dir, "aidd_docs", "runs"), { recursive: true }); + fs.mkdirSync(path.join(dir, ".git", "aidd", "runs"), { recursive: true }); fs.mkdirSync(path.join(dir, ".aidd"), { recursive: true }); fs.writeFileSync( path.join(dir, ".aidd", "config.json"), @@ -196,7 +196,7 @@ function sessionStartPayload(cwd, sessionId) { } function readRunFileLines(repo) { - const runsDir = path.join(repo, "aidd_docs", "runs"); + const runsDir = path.join(repo, ".git", "aidd", "runs"); const [fileName] = fs.readdirSync(runsDir).filter((entry) => entry.endsWith(".jsonl")); assert.ok(fileName, "a session_start run file must be written regardless of the manifest"); return fs diff --git a/scripts/__tests__/aidd-telemetry-runs-dir.test.js b/scripts/__tests__/aidd-telemetry-runs-dir.test.js index b5ded8034..bf9bb63a8 100644 --- a/scripts/__tests__/aidd-telemetry-runs-dir.test.js +++ b/scripts/__tests__/aidd-telemetry-runs-dir.test.js @@ -28,10 +28,10 @@ test("AIDD_RUNS_DIR overrides where runs are written", () => { const script = path.join(root, "plugins/aidd-telemetry/hooks/journal.cjs"); const repo = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-override-")); const runs = path.join(repo, "local-runs"); - const defaultRunsDir = path.join(repo, "aidd_docs", "runs"); + const legacyRunsDir = path.join(repo, "aidd_docs", "runs"); spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); - fs.mkdirSync(defaultRunsDir, { recursive: true }); + fs.mkdirSync(legacyRunsDir, { recursive: true }); writeTelemetryConfig(repo); spawnSync(process.execPath, [script, "session-start"], { @@ -48,8 +48,9 @@ test("AIDD_RUNS_DIR overrides where runs are written", () => { const written = fs.readdirSync(runs, { recursive: true }).filter((f) => String(f).endsWith(".jsonl")); assert.equal(written.length, 1, "the record did not land under AIDD_RUNS_DIR"); - const defaultWritten = fs.readdirSync(defaultRunsDir).filter((f) => f.endsWith(".jsonl")); + const defaultWritten = fs.readdirSync(legacyRunsDir).filter((f) => f.endsWith(".jsonl")); assert.equal(defaultWritten.length, 0, "the default aidd_docs/runs/ location was used anyway"); + assert.equal(fs.existsSync(path.join(repo, ".git", "aidd", "runs")), false, "the common git directory was used anyway"); fs.rmSync(repo, { recursive: true, force: true }); }); diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js index d1b6694cc..84c36147c 100644 --- a/scripts/__tests__/opencode-plugin.test.js +++ b/scripts/__tests__/opencode-plugin.test.js @@ -37,7 +37,7 @@ function makeInstalledRepo() { execFileSync("git", ["init", "-q"], { cwd: repo, env: CLEAN_ENV }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, env: CLEAN_ENV }); execFileSync("git", ["config", "user.name", "Test"], { cwd: repo, env: CLEAN_ENV }); - fs.mkdirSync(path.join(repo, "aidd_docs", "runs"), { recursive: true }); + fs.mkdirSync(path.join(repo, ".git", "aidd", "runs"), { recursive: true }); fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); fs.writeFileSync( path.join(repo, ".aidd", "config.json"), @@ -75,7 +75,7 @@ function makeInstalledRepo() { } function runsDirOf(repo) { - return path.join(repo, "aidd_docs", "runs"); + return path.join(repo, ".git", "aidd", "runs"); } function readRunLines(repo) { diff --git a/scripts/probe-identifier-join.cjs b/scripts/probe-identifier-join.cjs index 6b80e6079..77b500ec2 100755 --- a/scripts/probe-identifier-join.cjs +++ b/scripts/probe-identifier-join.cjs @@ -119,7 +119,8 @@ fs.writeFileSync(path.join(process.argv[3],process.argv[2]+".json"),raw)}catch{} /** The name is half the join: the hook writes `__.jsonl`. */ function journalledSessionIds(project) { - const dir = path.join(project, "aidd_docs", "runs"); + // The project is a git checkout, so the hook journals under its common git directory. + const dir = path.join(project, ".git", "aidd", "runs"); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir)