From e9e1f12db4a958adedd870629d0f5c69ce70899a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 08:50:16 -0700 Subject: [PATCH 01/15] docs(plan): Branch 1 imported rollout origins code-level plan --- ...09-27-branch-1-imported-rollout-origins.md | 575 ++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md diff --git a/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md b/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md new file mode 100644 index 00000000..e4563f9c --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md @@ -0,0 +1,575 @@ +# Branch 1: Imported Rollout Origins Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Codex rollouts that the ChatGPT desktop app imported from Claude Code transcripts stop giving project folders a Codex host or a Desktop origin, and every scan says how many it set aside. + +**Architecture:** One leaf module owns the single `external-import-turn` constant and two predicates: the existing line-level one and a new rollout-level wrapper over a bounded head. The usage parser, usage origin classifier and project discovery all use it. Discovery skips imported rollouts before reading their cwd and counts them (`importedExcluded`). The count flows up through the discovery payload and the Projects section, and the System KPI note shows it. + +**Tech Stack:** Node 22+/26 ESM (`src/lib/**/*.mjs`), `node:test`, the dashboard client bundle (`src/lib/dashboard/client/*.mjs`, loaded as text in tests). + +**Spec:** [Program plan, Branch 1](2026-09-26-remediation-program.md#branch-1-fiximported-rollout-origins); [ADR-0060 §3](../../adr/0060-session-surface-initiator-and-product-names.md) (imported copies excluded everywhere and counted); [ADR-0052 §3](../../adr/0052-codex-usage-attribution.md) (the in-rollout marker is the signal); [audit record, ADR-0060 section](../../audits/2026-09-26-issues-237-238-239-verification-and-decisions.md). + +## Global Constraints + +- Work only in `/Users/cphillipson/Development/active/ai/agentic-kit-b1` on branch `fix/imported-rollout-origins`. Its `node_modules` is a symlink: never run `pnpm` there. +- Commits: conventional subject, test-first (show the failing run, then the passing run), no `Co-Authored-By` or any trailer-like final line. Stage files by name; never `git add -A` or `git add .`. Never commit `.harness/`, `.swarm/`, `.claude-flow/` or `.agentic-qe/`. +- Never push, open or comment on pull requests or issues, or message other agents. +- Tests never write real user state: not `~/.config/agentic-kit`, not `~/.local/state/agentic-kit`, not a repository's `.claude`/`.swarm`/`.agentic-qe`/`.claude-flow`/`.harness`, not `~/.codex` or `~/.claude`. Fixtures live under `fs.mkdtempSync(path.join(os.tmpdir(), 'ak-…'))` and are removed in `t.after`. +- One constant: `CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'` exists in exactly one place after this branch. +- Official product names in any user-facing text: Claude Code, ChatGPT desktop app, Codex CLI. +- User-facing docs describe current state only; ADRs that change get an `Updated` line in this branch. +- Measurement reads of the real `~/.codex/sessions` are read-only and report counts only. + +## Verification of the scope against the code (2026-09-27, worktree at `be1c1d47`) + +| Item | Verdict | Evidence | +|---|---|---| +| Discovery records a sighting and a Desktop origin for imported rollouts | **Confirmed** | `src/lib/footprint/project-sources.mjs:304` pushes a sighting for every rollout with a cwd; `transcriptSessionOrigin` (`src/lib/footprint/session-origin.mjs:11-17`) maps `originator: "Codex Desktop"` to `codex-desktop` without looking at turn ids. | +| Size of the effect on the maintainer's machine | **Confirmed, numbers moved** | Read-only count on 2026-09-27: 1,692 rollouts; 924 imported, all with `originator: "Codex Desktop"`, all with a cwd, and in 924 of 924 the marker is on head line 2 (the first `event_msg/task_started`). 33 folders are named by an import; **32** show a Desktop origin only because of imports; **25** lose the Codex host entirely; **5** are named by nothing but an import and leave `everSeen` (114 today). The spec's 874 / 23 (2026-09-26) are the earlier measurement. One further file mentions the marker only in message text, not as a `turn_id`. | +| `usageSessionOrigin` labels imports `codex-desktop` | **Confirmed at the function, already neutral in effect** | `src/lib/usage-project-evidence.mjs:59-62` classifies the head without the marker, and `parseCodex` sets `rec.sessionOrigin` (`src/lib/usage-parsers.mjs:1230`) before stopping at the first imported line (`:1245`). But `recordCodexCandidate` counts the import and returns false (`src/lib/usage-index.mjs:698`) and `:920` keeps `session.imported` out of `records`, so no usage view ever shows that origin. The change here is for consistency and any future caller; it has no visible effect and needs no usage cache schema bump. | +| Maintenance "Session origin" facet | **Fixed by this branch with no extra code** | `src/lib/project-census.mjs:142` reads each project's `sessionOrigins`, which discovery builds. | +| Usage project groups (`usage-project-groups.mjs:4-6`) | **Already correct** | Built from `records`, which never contain imports. | +| The shared predicate "beside `CODEX_IMPORT_TURN_PREFIX` in `usage-parsers.mjs`" | **Placement changed (flagged)** | `usage-parsers.mjs:25` imports `usage-project-evidence.mjs`; `usage-project-evidence.mjs` does not reach `usage-parsers.mjs` today (checked by walking the import graph). Exporting the predicate from `usage-parsers.mjs` and importing it into `usage-project-evidence.mjs` would add a new import cycle. The plan moves the one constant and the existing `isCodexImportedLine` into a leaf module `src/lib/codex-import-marker.mjs` that all three modules import. Still one constant; the existing predicate is reused, not copied. | +| Storage attribution of rollout bytes to projects | **Out of scope** | `src/lib/footprint/storage.mjs:57,612` uses `transcriptMetadata` to attribute transcript bytes; those bytes are real disk use. Runtime and storage attribution belong to ADR-0060 Branch 7. | +| Head bound | **Accepted limit** | Discovery reads 256 KiB / 40 non-blank lines (`HEAD_BYTES`, `HEAD_MAX_LINES`). The usage parser scans the whole file for the marker. The measurement above found no imported rollout whose marker falls outside the head. | + +## Review Focus + +1. **A native rollout that merely mentions the marker** (a prompt or answer containing `external-import-turn-1`, as one real file does) must stay a normal sighting. Pinned in Task 1 (`native rollout that quotes the marker text still counts`). +2. **An old footprint snapshot without `importedExcluded`** must render exactly as today, with no "undefined" or "0 imported" text. Pinned in Task 2 (`no sentence when the field is absent or zero`). +3. **An imported rollout without a cwd** must count as imported, not as `withoutCwd`, and the per-file counts must still add up to `files`. Pinned in Task 1 (`the counts partition every file`). +4. **A Claude Code transcript head** is never tested against the Codex marker, even if some line carried a `payload.turn_id`. Pinned in Task 1 (`the Codex marker is never applied to Claude transcripts`). +5. **Excluding imports must not make the census look incomplete**: `complete` stays true, and `everSeen` drops only for folders that nothing but an import names. Pinned in Task 1 (`discovery keeps a folder a Claude transcript names`, `an import-only folder is not a project`). + +--- + +## Slice 1 (one implementer, tasks run in order) + +All tasks below: shared rollout-level predicate, both call sites, excluded count, docs. + +### File map + +- Create: `src/lib/codex-import-marker.mjs`: the single constant, `isCodexImportedLine(e)`, `isImportedCodexRollout(headLines)`. +- Modify: `src/lib/usage-parsers.mjs:1149-1159`: delete the local constant and function; import `isCodexImportedLine` from the new module. The doc comment moves with them. +- Modify: `src/lib/usage-project-evidence.mjs:58-62`: `usageSessionOrigin` returns `{ origin: 'unknown', evidence: 'imported-copy' }` for an imported Codex head. +- Modify: `src/lib/footprint/project-sources.mjs`: `scanTranscriptCwds` (base object `:262-278`, loop `:293-307`, return `:322-336`) and `discoverProjectSources` (return `:511-523`, JSDoc `:414-427`). +- Modify: `src/lib/footprint/projects.mjs`: `summarizeCatalog` (`:615`), `resolveProjectCatalog` counts (`:709-718`), `buildProjectsSection` (`:797-801`). +- Modify: `src/lib/dashboard/client/system-readout.mjs:367-388` (`projectsLiner`). +- Create: `tests/kit/project-sources-imports.test.mjs`. +- Modify: `tests/kit/system-summary.test.mjs` (one new test beside the KPI rendering tests). +- Docs: `docs/adr/0052-codex-usage-attribution.md`, `docs/adr/0060-session-surface-initiator-and-product-names.md`, `docs/ddd/machine-footprint.md:648-651`, `docs/DASHBOARD.md` (the "A session cwd is only a discovery candidate" paragraph, near `:639`), `docs/MAINTENANCE.md:110-112`, `docs/TRANSCRIPTS.md:136-142`. + +### Task 1: Shared predicate, both call sites and the discovery count + +**Files:** + +- Create: `src/lib/codex-import-marker.mjs` +- Modify: `src/lib/usage-parsers.mjs:1149-1159` +- Modify: `src/lib/usage-project-evidence.mjs:1-10,58-62` +- Modify: `src/lib/footprint/project-sources.mjs:41,262-336,414-427,511-523` +- Modify: `src/lib/footprint/projects.mjs:615,709-718,797-801` +- Test: `tests/kit/project-sources-imports.test.mjs` + +**Interfaces:** + +- Produces: + - `export const CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'` (the only definition in `src/`). + - `export function isCodexImportedLine(e: object|null|undefined): boolean`: true when `e.payload.turn_id` is a string starting with the prefix. Same behavior as today's private function. + - `export function isImportedCodexRollout(headLines: string[]|null|undefined): boolean`: true when any line parses as JSON and satisfies `isCodexImportedLine`. Non-string entries and unparseable lines are skipped. A line that does not contain the prefix text is not parsed. + - `usageSessionOrigin(raw, 'codex')` returns `{ origin: 'unknown', evidence: 'imported-copy' }` for an imported head; otherwise unchanged. + - `scanTranscriptCwds(...)` result gains `importedExcluded: number` (always present; 0 for Claude and for absent/degraded roots). + - `discoverProjectSources(...)` result gains top-level `importedExcluded: number` (sum over hosts, like `unresolved`). + - The `collectProjects(...)` section gains `importedExcluded: number` (0 when the catalog came as an array or discovery failed). + +- [ ] **Step 1: Write the failing test** + +Create `tests/kit/project-sources-imports.test.mjs`: + +```js +// Imported Codex rollouts (the ChatGPT desktop app's "Import from another +// agent" copies of Claude Code transcripts, turn ids `external-import-turn-N`) +// are not Codex activity: they give a folder no Codex host, no Desktop origin +// and no project, and every scan counts them. ADR-0052 §3, ADR-0060 §3. +// Fixtures live in a temporary folder; nothing reads ~/.codex or ~/.claude. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Rollout } from './helpers/codex-rollout.mjs'; +import { CODEX_IMPORT_TURN_PREFIX, isCodexImportedLine, isImportedCodexRollout } from '../../src/lib/codex-import-marker.mjs'; +import { scanTranscriptCwds, discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; +import { collectProjects } from '../../src/lib/footprint/projects.mjs'; +import { usageSessionOrigin } from '../../src/lib/usage-project-evidence.mjs'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; + +const realpath = (file) => (fs.realpathSync.native ?? fs.realpathSync)(file); + +function fixture(t) { + const root = realpath(fs.mkdtempSync(path.join(os.tmpdir(), 'ak-imported-rollouts-'))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function importedRollout(id, cwd) { + return new Rollout({ id }) + .meta({ cwd, originator: 'Codex Desktop', source: 'vscode', thread_source: undefined }) + .taskStarted(`${CODEX_IMPORT_TURN_PREFIX}-1`).user('imported prompt').agent('imported answer') + .lines; +} + +function nativeRollout(id, cwd, { originator = 'codex_work_desktop', prompt = 'do the thing' } = {}) { + return new Rollout({ id }).meta({ cwd, originator }).turn('gpt-5.6', { cwd }) + .taskStarted('t1').user(prompt).agent('done').lines; +} + +function writeRollout(dir, name, lines) { + const file = path.join(dir, '2026', '09', '27', `rollout-${name}.jsonl`); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${lines.join('\n')}\n`); + return file; +} + +test('the rollout predicate reads only a string payload.turn_id with the import prefix', () => { + assert.equal(isCodexImportedLine({ payload: { turn_id: 'external-import-turn-3' } }), true); + assert.equal(isCodexImportedLine({ payload: { turn_id: 't1' } }), false); + assert.equal(isCodexImportedLine({ payload: { turn_id: 7 } }), false); + assert.equal(isCodexImportedLine(null), false); + assert.equal(isImportedCodexRollout(importedRollout('a', '/p')), true); + assert.equal(isImportedCodexRollout(nativeRollout('b', '/p')), false); + assert.equal(isImportedCodexRollout(['{not json external-import-turn-1', 42, null]), false); + assert.equal(isImportedCodexRollout(null), false); +}); + +test('native rollout that quotes the marker text still counts', () => { + const lines = nativeRollout('c', '/p', { prompt: 'why is my turn id external-import-turn-1?' }); + assert.equal(isImportedCodexRollout(lines), false); +}); + +test('an imported rollout contributes no sighting and is counted; a native Desktop one still is', (t) => { + const root = fixture(t); + const importedDir = path.join(root, 'imported-project'), nativeDir = path.join(root, 'native-project'); + fs.mkdirSync(importedDir); fs.mkdirSync(nativeDir); + const sessions = path.join(root, 'sessions'); + writeRollout(sessions, 'imported', importedRollout('imp', importedDir)); + writeRollout(sessions, 'native', nativeRollout('nat', nativeDir)); + const scan = scanTranscriptCwds(sessions, 'codex'); + assert.deepEqual(scan.sightings.map(({ cwd, sessionOrigin }) => [cwd, sessionOrigin.origin]), + [[nativeDir, 'codex-desktop']]); + assert.equal(scan.importedExcluded, 1); + assert.equal(scan.complete, true, 'setting an import aside is not a gap in the census'); +}); + +test('the counts partition every file', (t) => { + const root = fixture(t), sessions = path.join(root, 'sessions'); + writeRollout(sessions, 'imported', importedRollout('imp', root)); + writeRollout(sessions, 'imported-no-cwd', importedRollout('imp2', undefined)); + writeRollout(sessions, 'native', nativeRollout('nat', root)); + writeRollout(sessions, 'native-no-cwd', new Rollout({ id: 'x' }).raw('event_msg', { type: 'task_started', turn_id: 't1' }).lines); + fs.writeFileSync(path.join(sessions, 'empty.jsonl'), ''); + const scan = scanTranscriptCwds(sessions, 'codex'); + assert.equal(scan.importedExcluded, 2, 'an import without a cwd is still an import'); + assert.equal(scan.withoutCwd, 1); + assert.equal(scan.files, scan.withCwd + scan.withoutCwd + scan.empty + scan.unreadable + scan.importedExcluded); +}); + +test('the Codex marker is never applied to Claude transcripts', (t) => { + const root = fixture(t), projects = path.join(root, 'projects', '-encoded'); + fs.mkdirSync(projects, { recursive: true }); + fs.writeFileSync(path.join(projects, 's.jsonl'), `${JSON.stringify({ + cwd: root, sessionId: 's', entrypoint: 'cli', payload: { turn_id: 'external-import-turn-1' }, + })}\n`); + const scan = scanTranscriptCwds(path.join(root, 'projects'), 'claude'); + assert.equal(scan.sightings.length, 1); + assert.equal(scan.importedExcluded, 0); +}); + +function discover(root, { claudeCwd = null } = {}) { + const claudeRoot = path.join(root, 'claude-projects'); + fs.mkdirSync(path.join(claudeRoot, '-encoded'), { recursive: true }); + if (claudeCwd) { + fs.writeFileSync(path.join(claudeRoot, '-encoded', 's.jsonl'), + `${JSON.stringify({ cwd: claudeCwd, sessionId: 's', entrypoint: 'cli', timestamp: '2026-09-27T00:00:00Z' })}\n`); + } + return discoverProjectSources({ + claudeRoot, codexRoot: path.join(root, 'sessions'), opencodeDbFile: path.join(root, 'absent.db'), + decodeEncodedDirs: false, resolveLabel: (p) => path.basename(p), + }); +} + +test('discovery keeps a folder a Claude transcript names, without a Codex host or Desktop origin', (t) => { + const root = fixture(t), shared = path.join(root, 'shared'); + fs.mkdirSync(shared); + writeRollout(path.join(root, 'sessions'), 'imported', importedRollout('imp', shared)); + const payload = discover(root, { claudeCwd: shared }); + const row = payload.projects.find((p) => p.path === shared); + assert.deepEqual(row.hosts, ['claude']); + assert.deepEqual(row.sessionOrigins.map((o) => o.origin), ['unknown']); + assert.equal(payload.importedExcluded, 1); + assert.equal(payload.complete, true); +}); + +test('an import-only folder is not a project', (t) => { + const root = fixture(t), only = path.join(root, 'only-imported'); + fs.mkdirSync(only); + writeRollout(path.join(root, 'sessions'), 'imported', importedRollout('imp', only)); + const payload = discover(root); + assert.equal(payload.projects.some((p) => p.path === only), false); + assert.equal(payload.everSeen, 0); + assert.equal(payload.importedExcluded, 1); +}); + +test('the Projects section carries the discovery count', () => { + const section = collectProjects({ + sources: { projects: [], everSeen: 0, onDisk: 0, gitRepos: 0, unresolved: 0, importedExcluded: 3, complete: true, method: 'm', sources: {} }, + loc: false, now: () => 1, + }); + assert.equal(section.importedExcluded, 3); + assert.equal(collectProjects({ projects: [], loc: false, now: () => 1 }).importedExcluded, 0); +}); + +test('the usage origin of an imported head is unknown and says why', () => { + const imported = importedRollout('imp', '/p').join('\n'); + assert.deepEqual(usageSessionOrigin(imported, 'codex'), { origin: 'unknown', evidence: 'imported-copy' }); + assert.equal(usageSessionOrigin(nativeRollout('n', '/p').join('\n'), 'codex').origin, 'codex-desktop'); + const parsed = parseCodex(imported, { id: 'imp' }); + assert.equal(parsed.session.imported, true); + assert.equal(parsed.session.sessionOrigin.origin, 'unknown'); +}); +``` + +Notes for the implementer: `Rollout.meta` spreads `extra` over `{ id, cwd: '/Users/me/proj', thread_source: 'user' }`, so `cwd: undefined` produces a `session_meta` without a usable cwd (JSON drops `undefined`), and `thread_source: undefined` removes it, as in a real import. If `collectProjects` with an empty `sources` payload needs more options to run hermetically (it should not walk anything when there are no rows), pass `walk: () => ({ complete: true })` and `detect: () => ({})` rather than changing the product code for the test. + +- [ ] **Step 2: Run it and watch it fail** + +Run: `node --test tests/kit/project-sources-imports.test.mjs` +Expected: FAIL at import time with `Cannot find module '…/src/lib/codex-import-marker.mjs'`. Record the output for the commit evidence. + +- [ ] **Step 3: Create the leaf module** + +Create `src/lib/codex-import-marker.mjs`: + +```js +// The one place that knows how an imported Codex rollout is marked. +// +// Codex (the ChatGPT desktop app's "Import from another agent") can copy a +// Claude Code transcript in as a thread. The host stamps such a rollout's turns +// `external-import-turn-N` (measured 2026-09-19: 796 imports, every one carrying +// it from its first task_started, none of a native thread; 2026-09-27: 924 of +// 924 on the rollout's second line). The in-rollout marker is the signal: the +// host's imports file is deliberately not read, so detection works without it. +// Usage parsing, usage origin and project discovery all use these predicates +// (ADR-0052 §3, ADR-0060 §3). Leaf module: imports nothing, so any of them can +// use it without an import cycle. + +export const CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'; + +/** One decoded rollout record: is it a turn of an imported thread? Only a + * string `payload.turn_id` counts; the marker text inside a message does not. */ +export function isCodexImportedLine(e) { + const turnId = e?.payload?.turn_id; + return typeof turnId === 'string' && turnId.startsWith(CODEX_IMPORT_TURN_PREFIX); +} + +/** A rollout's bounded head (raw JSON lines): is the rollout an imported copy? + * A line without the marker text is not parsed, which keeps this cheap on the + * large native heads; unparseable and non-string lines are skipped. */ +export function isImportedCodexRollout(headLines) { + for (const line of headLines ?? []) { + if (typeof line !== 'string' || !line.includes(CODEX_IMPORT_TURN_PREFIX)) continue; + let record; + try { record = JSON.parse(line); } catch { continue; } + if (isCodexImportedLine(record)) return true; + } + return false; +} +``` + +- [ ] **Step 4: Point the usage parser at it** + +In `src/lib/usage-parsers.mjs`, delete lines `1149-1159` (the doc comment, `const CODEX_IMPORT_TURN_PREFIX` and `function isCodexImportedLine`) and add to the import block near line 25: + +```js +import { isCodexImportedLine } from './codex-import-marker.mjs'; +``` + +The call site at `:1245` (`if (isCodexImportedLine(e)) return importedCodexSession(rec, stats);`) stays unchanged. Check: `grep -rn "external-import-turn'" src` prints only `src/lib/codex-import-marker.mjs`. + +- [ ] **Step 5: Classify an imported head in `usageSessionOrigin`** + +In `src/lib/usage-project-evidence.mjs`, add the import and replace the function: + +```js +import { isImportedCodexRollout } from './codex-import-marker.mjs'; +``` + +```js +/** Same bounded head and exact origin allowlists as footprint discovery. An + * imported Codex copy of a Claude Code transcript declares the ChatGPT desktop + * app as its originator but is not a session from it (ADR-0060 §3). */ +export function usageSessionOrigin(raw, host) { + const head = Buffer.from(String(raw).slice(0, 256 * 1024)).subarray(0, 256 * 1024).toString('utf8'); + const lines = head.split('\n').filter((line) => line.trim()).slice(0, 40); + if (host === 'codex' && isImportedCodexRollout(lines)) return { origin: 'unknown', evidence: 'imported-copy' }; + return transcriptSessionOrigin(lines, host); +} +``` + +- [ ] **Step 6: Skip and count imports in discovery** + +In `src/lib/footprint/project-sources.mjs`: + +1. Add `import { isImportedCodexRollout } from '../codex-import-marker.mjs';` after the `session-origin.mjs` import (`:41`). +2. In the `base` object of `scanTranscriptCwds`, add `importedExcluded: 0,` after `recoveredFromDirName: 0,`. +3. Declare `let importedExcluded = 0;` beside `let unreadable = 0;`. +4. In the loop, after the `empty` check and before `firstCwd`: + +```js + // An imported copy of a Claude Code transcript is not a Codex session: it + // names the folder the Claude session ran in and declares the ChatGPT + // desktop app as originator. It gives no project, host or origin and is + // counted, never dropped silently (ADR-0052 §3, ADR-0060 §3). + if (host === 'codex' && isImportedCodexRollout(lines)) { importedExcluded += 1; continue; } +``` + +Then, in the final return, add `importedExcluded,` after `recoveredFromDirName,`. Leave `complete` as it is: an import set aside is not an unreadable file. + +Finally, in `discoverProjectSources`, beside `unresolved`: + +```js + const importedExcluded = PROJECT_SOURCE_HOSTS + .reduce((total, host) => total + (sources[host]?.importedExcluded ?? 0), 0); +``` + +and add `importedExcluded,` to the returned object after `unresolved,`. Extend the `@returns` JSDoc with `importedExcluded: number` and one sentence: "`importedExcluded` counts Codex rollouts that are imported copies of Claude Code transcripts, which name no project." + +- [ ] **Step 7: Carry the count into the Projects section** + +In `src/lib/footprint/projects.mjs`: + +- `summarizeCatalog` return (`:615`): `{ everSeen: rows.length, onDisk, gitRepos, unresolved: 0, importedExcluded: 0, complete: true }`. +- `resolveProjectCatalog` counts (`:709-718`): add `importedExcluded: payload?.importedExcluded ?? 0,` after `unresolved`. +- `buildProjectsSection` (`:800`): add `importedExcluded: counts?.importedExcluded ?? 0,` after `unresolved`. + +`systemSummaryPayload` (`src/lib/dashboard/system-summary.mjs:61`) passes `projects` through untouched, so `/api/system/summary` carries the field with no change there. + +- [ ] **Step 8: Run the focused tests and watch them pass** + +Run: + +```bash +node --test tests/kit/project-sources-imports.test.mjs tests/kit/dashboard-project-identity.test.mjs tests/kit/footprint-projects.test.mjs tests/kit/usage-codex-attribution.test.mjs tests/kit/project-census.test.mjs tests/kit/usage-index-v6.test.mjs +``` + +Expected: PASS, 0 failures. If a `deepEqual` on a whole scan or section object elsewhere fails only because of the new field, add the field to that expectation and name the test in the commit body. + +- [ ] **Step 9: Lint and type-check the touched files** + +```bash +npx eslint src/lib/codex-import-marker.mjs src/lib/usage-parsers.mjs src/lib/usage-project-evidence.mjs src/lib/footprint/project-sources.mjs src/lib/footprint/projects.mjs tests/kit/project-sources-imports.test.mjs +npx eslint src/lib/footprint/project-sources.mjs --rule 'complexity: [2, 50]' +npx tsc -p tsconfig.json +``` + +Expected: no errors. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/cphillipson/Development/active/ai/agentic-kit-b1 +git add src/lib/codex-import-marker.mjs src/lib/usage-parsers.mjs src/lib/usage-project-evidence.mjs src/lib/footprint/project-sources.mjs src/lib/footprint/projects.mjs tests/kit/project-sources-imports.test.mjs +git commit -m "fix(discovery): keep imported Codex copies out of project origins" -m "Rollouts the ChatGPT desktop app imported from Claude Code transcripts (turn ids external-import-turn-N) no longer give a folder a Codex host, a Desktop origin or a project row. Discovery counts them as importedExcluded, and the count reaches the Projects section. The one marker constant moves to a leaf module used by the usage parser, usage origin and discovery, so no import cycle is added. Failing first: the new test file failed on the missing module; passing after: ." +``` + +### Task 2: Say how many imports discovery set aside + +**Files:** + +- Modify: `src/lib/dashboard/client/system-readout.mjs:367-388` (`projectsLiner`) +- Test: `tests/kit/system-summary.test.mjs` (add one test after the "KPI band and every catalog card" test, near `:218`) + +**Interfaces:** + +- Consumes: `projects.importedExcluded: number|undefined` from Task 1 (the Projects section). +- Produces: the `sys-kpis-note` HTML gains one sentence when `importedExcluded > 0`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/kit/system-summary.test.mjs`, reusing the file's `systemClient()` and `fullPayload()`: + +```js +test('the projects note says how many imported copies discovery set aside, and nothing when there are none', () => { + const noteFor = (extra) => { + const client = systemClient(); + const projects = { everSeen: { value: 114 }, onDisk: { value: 90 }, gitRepos: { value: 60 }, unresolved: 0, + method: 'm', projects: [], ...extra }; + client.readout.renderSysKpis({ ...fullPayload(1), projects }); + return client.document.getElementById('sys-kpis-note').innerHTML; + }; + const note = noteFor({ importedExcluded: 924 }); + assert.match(note, /924 Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted/); + assert.match(noteFor({ importedExcluded: 1 }), /1 Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted/); + for (const extra of [{}, { importedExcluded: 0 }]) { + const plain = noteFor(extra); + assert.doesNotMatch(plain, /imported|undefined/, 'an old snapshot or a zero renders exactly as before'); + } +}); +``` + +If `fakeDocument().getElementById` in this file returns `null` for ids it has not seen, use the same accessor the neighbouring test uses (`[...client.document.elements]`) to read `sys-kpis-note`; do not change the fake. + +- [ ] **Step 2: Run it and watch it fail** + +Run: `node --test tests/kit/system-summary.test.mjs --test-name-pattern "imported copies"` +Expected: FAIL on the first `assert.match` (the note has no such sentence). + +- [ ] **Step 3: Add the sentence** + +In `projectsLiner`, after the `unresolved` block and before the `truncated` line: + +```js + var imported=typeof p.importedExcluded==="number"&&p.importedExcluded>0?p.importedExcluded:0; + if(imported){ + note+=" "+esc(fmtNum(imported))+(imported===1 + ?" Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted" + :" Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted") + +"; the Claude Code transcript already names its folder."; + } +``` + +- [ ] **Step 4: Run it and watch it pass** + +Run: `node --test tests/kit/system-summary.test.mjs` +Expected: PASS, including the existing "renders identically from the summary and the full payload" test. + +- [ ] **Step 5: Lint and commit** + +```bash +cd /Users/cphillipson/Development/active/ai/agentic-kit-b1 +npx eslint src/lib/dashboard/client/system-readout.mjs tests/kit/system-summary.test.mjs +git add src/lib/dashboard/client/system-readout.mjs tests/kit/system-summary.test.mjs +git commit -m "feat(system): say how many imported Codex copies project discovery set aside" -m "Failing first: the new system-summary test failed on the missing sentence; passing after: ." +``` + +### Task 3: Documentation + +**Files:** + +- Modify: `docs/adr/0052-codex-usage-attribution.md` (header and §3) +- Modify: `docs/adr/0060-session-surface-initiator-and-product-names.md` (header) +- Modify: `docs/ddd/machine-footprint.md:648-651` +- Modify: `docs/DASHBOARD.md` (the "A session cwd is only a discovery candidate" paragraph, near `:639-642`) +- Modify: `docs/MAINTENANCE.md:110-112` +- Modify: `docs/TRANSCRIPTS.md:136-142` + +Documentation has no failing test of its own; its gates are `npx markdownlint-cli2` and `node --test tests/kit/doc-citations.test.mjs`. Do not add `file.mjs:NNN` citations to these docs unless you check them against that test: it verifies the cited line against the current source. + +- [ ] **Step 1: ADR-0052.** After `- **Date:** 2026-09-19` add: + +```markdown +- **Updated:** 2026-09-27 — imported copies are also excluded from project discovery: they give no + project, host or Desktop origin, and the discovery scan counts them in `importedExcluded` + (ADR-0060 §3). The marker now lives in one leaf module shared by usage and discovery. +``` + +At the end of §3 (after "The record itself is still cached, so a rescan is cheap."), add one paragraph: + +```markdown +Project discovery applies the same marker to each rollout's bounded head (256 KiB, 40 lines): an +imported copy names no project, host or Desktop origin, and the scan reports how many it set aside +(`importedExcluded`, 924 on the reference machine on 2026-09-27, every marker on the rollout's +second line). +``` + +- [ ] **Step 2: ADR-0060.** Leave `Status` as the maintainer set it. Change `(staged follow-on; nothing implemented)` to `(staged follow-on)` and add under `Date`: + +```markdown +- **Updated:** 2026-09-27 — §3 implemented for project discovery and the System projects note: + imported copies give no project, host or origin and are counted. The ledger-derived source labels + (Cursor, Cowork) and the other views remain proposed. +``` + +- [ ] **Step 3: DDD.** In `docs/ddd/machine-footprint.md`, replace the sentence of the "Proposed change" paragraph that begins "Imported session copies" with current state, and move it out of the "Proposed change" paragraph as its own paragraph: + +```markdown +Imported session copies are not sightings. A Codex rollout stamped `external-import-turn-*` is a +Claude Code transcript that the ChatGPT desktop app imported; the Claude transcript already names +its folder. Discovery skips it before reading its cwd, so it adds no project, host or Session +origin, and counts it in `importedExcluded` (per host scan and in total). `complete` is unaffected. +``` + +- [ ] **Step 4: DASHBOARD.md.** Append to the "A session cwd is only a discovery candidate" paragraph: + +```markdown +A Codex session that the ChatGPT desktop app imported from a Claude Code transcript is not a +candidate at all: it adds no project, host or Desktop origin, and the Projects note under the System +KPIs says how many were set aside. +``` + +- [ ] **Step 5: MAINTENANCE.md.** After "Desktop origin never replaces repository membership." insert: "Codex copies of Claude Code sessions imported by the ChatGPT desktop app never count as a Desktop origin." + +- [ ] **Step 6: TRANSCRIPTS.md.** At the end of the "Imported Claude sessions" bullet, add: "Project discovery reads the same marker in each rollout's head and leaves the rollout out of projects, hosts and Desktop origins, counting it in the discovery scan's `importedExcluded`." + +- [ ] **Step 7: Check the product names and run the doc gates** + +```bash +cd /Users/cphillipson/Development/active/ai/agentic-kit-b1 +npx markdownlint-cli2 +node --test tests/kit/doc-citations.test.mjs tests/kit/ga-surface-guard.test.mjs +``` + +Expected: 0 errors, PASS. Also re-read ADR-0050 (session origin rule) and `docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md` (ADR-0060 section, "staged as follow-on, starting with that exclusion") for anything the change now contradicts; the audit record is history, so leave it unless it states a current fact that is now false. + +- [ ] **Step 8: Commit** + +```bash +git add docs/adr/0052-codex-usage-attribution.md docs/adr/0060-session-surface-initiator-and-product-names.md docs/ddd/machine-footprint.md docs/DASHBOARD.md docs/MAINTENANCE.md docs/TRANSCRIPTS.md +git commit -m "docs(adr): record that discovery excludes imported copies (ADR-0052)" +``` + +### Task 4: Full gate set and real-machine check (gate agent) + +- [ ] **Step 1: Run the gate set from the worktree, exactly as in the common brief** + +```bash +cd /Users/cphillipson/Development/active/ai/agentic-kit-b1 +S=$(mktemp -d "$PWD/../ak-gate.XXXXXX") +FP=/Users/cphillipson/Development/active/ai/agentic-kit/.superpowers/sdd/2026-09-26-remediation-program/briefs/fingerprint.sh +$FP "$PWD" > "$S/fp-before.txt" +env XDG_STATE_HOME="$S/state" LOCALAPPDATA="$S/localappdata" node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 "tests/kit/*.test.mjs" +env XDG_STATE_HOME="$S/state22" mise exec node@22.22.3 -- node --test "tests/kit/*.test.mjs" +for f in statusline-segments statusline-window-ledger statusline-brain health-history dashboard admin-model admin; do env XDG_STATE_HOME="$S/state" node "tests/$f.test.cjs" || echo "FAIL $f"; done +npx tsc -p tsconfig.json +npx eslint . --ignore-pattern '.superpowers/**' +npx eslint src bin --rule 'complexity: [2, 50]' +npx markdownlint-cli2 +node scripts/build-check.mjs +env XDG_STATE_HOME="$S/state" node tests/ui/dashboard-ui.mjs && env XDG_STATE_HOME="$S/state" node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs tests/ui/host-readiness.mjs +node --test tests/kit/doc-citations.test.mjs tests/kit/ga-surface-guard.test.mjs +npm pack --dry-run 2>&1 | grep codex-import-marker # the new runtime module ships +$FP "$PWD" > "$S/fp-after.txt"; diff "$S/fp-before.txt" "$S/fp-after.txt" +rm -rf "$S" +``` + +Expected: 0 failures; coverage at or above 70/70/70; eslint 0 errors; `npm pack` lists `src/lib/codex-import-marker.mjs`; the fingerprint diff is empty (or each line is explained with evidence). + +- [ ] **Step 2: Read-only check on the real machine (counts only)** + +```bash +cd /Users/cphillipson/Development/active/ai/agentic-kit-b1 +node --input-type=module -e " +import { discoverProjectSources } from './src/lib/footprint/project-sources.mjs'; +const p = discoverProjectSources(); +const desktop = p.projects.filter((r) => r.sessionOrigins.some((o) => o.origin === 'codex-desktop')).length; +console.log(JSON.stringify({ everSeen: p.everSeen, importedExcluded: p.importedExcluded, codexImported: p.sources.codex.importedExcluded, foldersWithCodexDesktop: desktop, complete: p.complete })); +" +``` + +Expected on the maintainer's machine: `importedExcluded` about 924 (grows with new imports), `everSeen` about 5 lower than before the branch (114 on 2026-09-27), and `foldersWithCodexDesktop` equal to the folders genuine ChatGPT desktop app sessions name (17 on 2026-09-26). Record the figures in the branch report; this is discovery only and writes nothing. + +## Self-review + +- Spec coverage: program-plan interface `isImportedCodexRollout(headLines: string[]): boolean` (Task 1 Step 3); fixture with `turn_id: "external-import-turn-1"` gives no sighting and no origin, a native `codex_work_desktop` rollout still does, scan reports `importedExcluded: 1` (Task 1 tests 3 and 6); both call sites (Steps 5 and 6); count surfaced where the scan result is reported (Steps 6 and 7, Task 2); the listed docs (Task 3). ADR-0060 §3's per-source labels from the imports ledger (Cursor, Cowork) are not in the program-plan scope and stay proposed. +- Commit subjects match the program plan for the fix and the docs; Task 2 adds one `feat(system)` commit because the note is a separately reviewable user-facing change. +- Names used across tasks: `CODEX_IMPORT_TURN_PREFIX`, `isCodexImportedLine`, `isImportedCodexRollout`, `importedExcluded`, evidence value `imported-copy`. From 0c56f48f153f72168e884182da0076658273b7ca Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 08:52:20 -0700 Subject: [PATCH 02/15] fix(discovery): keep imported Codex copies out of project origins Rollouts the ChatGPT desktop app imported from Claude Code transcripts (turn ids external-import-turn-N) no longer give a folder a Codex host, a Desktop origin or a project row. Discovery counts them as importedExcluded, and the count reaches the Projects section. The one marker constant moves to a leaf module used by the usage parser, usage origin and discovery, so no import cycle is added. The payload counts move into a small helper so resolveProjectCatalog stays under the complexity warning. Failing first: tests/kit/project-sources-imports.test.mjs failed with ERR_MODULE_NOT_FOUND for src/lib/codex-import-marker.mjs; passing after: 9/9, and 204/204 across the focused footprint, census and usage files. --- src/lib/codex-import-marker.mjs | 33 +++++ src/lib/footprint/project-sources.mjs | 16 ++- src/lib/footprint/projects.mjs | 28 ++-- src/lib/usage-parsers.mjs | 13 +- src/lib/usage-project-evidence.mjs | 9 +- tests/kit/project-sources-imports.test.mjs | 150 +++++++++++++++++++++ 6 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 src/lib/codex-import-marker.mjs create mode 100644 tests/kit/project-sources-imports.test.mjs diff --git a/src/lib/codex-import-marker.mjs b/src/lib/codex-import-marker.mjs new file mode 100644 index 00000000..1be22e44 --- /dev/null +++ b/src/lib/codex-import-marker.mjs @@ -0,0 +1,33 @@ +// The one place that knows how an imported Codex rollout is marked. +// +// Codex (the ChatGPT desktop app's "Import from another agent") can copy a +// Claude Code transcript in as a thread. The host stamps such a rollout's turns +// `external-import-turn-N` (measured 2026-09-19: 796 imports, every one carrying +// it from its first task_started, none of a native thread; 2026-09-27: 924 of +// 924 on the rollout's second line). The in-rollout marker is the signal: the +// host's imports file is deliberately not read, so detection works without it. +// Usage parsing, usage origin and project discovery all use these predicates +// (ADR-0052 §3, ADR-0060 §3). Leaf module: imports nothing, so any of them can +// use it without an import cycle. + +export const CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'; + +/** One decoded rollout record: is it a turn of an imported thread? Only a + * string `payload.turn_id` counts; the marker text inside a message does not. */ +export function isCodexImportedLine(e) { + const turnId = e?.payload?.turn_id; + return typeof turnId === 'string' && turnId.startsWith(CODEX_IMPORT_TURN_PREFIX); +} + +/** A rollout's bounded head (raw JSON lines): is the rollout an imported copy? + * A line without the marker text is not parsed, which keeps this cheap on the + * large native heads; unparseable and non-string lines are skipped. */ +export function isImportedCodexRollout(headLines) { + for (const line of headLines ?? []) { + if (typeof line !== 'string' || !line.includes(CODEX_IMPORT_TURN_PREFIX)) continue; + let record; + try { record = JSON.parse(line); } catch { continue; } + if (isCodexImportedLine(record)) return true; + } + return false; +} diff --git a/src/lib/footprint/project-sources.mjs b/src/lib/footprint/project-sources.mjs index 53634da9..be8e1421 100644 --- a/src/lib/footprint/project-sources.mjs +++ b/src/lib/footprint/project-sources.mjs @@ -39,6 +39,7 @@ import { defaultOpencodeDbPath } from '../usage-opencode.mjs'; import { presenceOf, statNode, UNKNOWN, walkTree } from './walk.mjs'; import { inspectProjectIdentity } from './project-identity.mjs'; import { transcriptSessionOrigin } from './session-origin.mjs'; +import { isImportedCodexRollout } from '../codex-import-marker.mjs'; /** Hosts in the order every payload lists them. */ export const PROJECT_SOURCE_HOSTS = Object.freeze(['claude', 'codex', 'opencode']); @@ -267,6 +268,7 @@ export function scanTranscriptCwds(root, host, { unreadable: 0, unresolved: 0, recoveredFromDirName: 0, + importedExcluded: 0, sightings: [], truncated: Boolean(result.truncated), truncatedBy: result.truncatedBy ?? null, @@ -284,6 +286,7 @@ export function scanTranscriptCwds(root, host, { let withoutCwd = 0; let empty = 0; let unreadable = 0; + let importedExcluded = 0; for (const { file, mtimeMs } of files) { let group = null; @@ -298,6 +301,11 @@ export function scanTranscriptCwds(root, host, { const lines = readHead(file, { fsImpl, headBytes, maxLines }); if (lines === null) { unreadable += 1; continue; } if (lines.length === 0) { empty += 1; continue; } + // An imported copy of a Claude Code transcript is not a Codex session: it + // names the folder the Claude session ran in and declares the ChatGPT + // desktop app as originator. It gives no project, host or origin and is + // counted, never dropped silently (ADR-0052 §3, ADR-0060 §3). + if (host === 'codex' && isImportedCodexRollout(lines)) { importedExcluded += 1; continue; } const cwd = firstCwd(lines, host); if (!cwd) { withoutCwd += 1; continue; } withCwd += 1; @@ -327,6 +335,7 @@ export function scanTranscriptCwds(root, host, { unreadable, unresolved, recoveredFromDirName, + importedExcluded, sightings, // Transcripts we could not read, and project directories whose path could // not be recovered, both mean the project list is a floor. @@ -419,11 +428,13 @@ function gitPresence(projectPath, fsImpl) { * exists: boolean, isGitRepo: boolean, lastSeenMs: number|null, * sessions: number }>, * everSeen: number, onDisk: number, gitRepos: number, unresolved: number, - * complete: boolean, method: string, + * importedExcluded: number, complete: boolean, method: string, * sources: Record<'claude'|'codex'|'opencode', object>, * }} `everSeen` counts projects INCLUDING vanished ones; `onDisk` counts the * measurable subset. `complete: false` means at least one transcript or * project directory could not be resolved, so both counts are lower bounds. + * `importedExcluded` counts Codex rollouts that are imported copies of Claude + * Code transcripts, which name no project. */ export function discoverProjectSources({ claudeRoot = path.join(claudeDir(), 'projects'), @@ -510,6 +521,8 @@ export function discoverProjectSources({ const unresolved = PROJECT_SOURCE_HOSTS .reduce((total, host) => total + (sources[host]?.unresolved ?? 0), 0); + const importedExcluded = PROJECT_SOURCE_HOSTS + .reduce((total, host) => total + (sources[host]?.importedExcluded ?? 0), 0); return { asOf, projects, @@ -517,6 +530,7 @@ export function discoverProjectSources({ onDisk: projects.filter((project) => project.exists).length, gitRepos: projects.filter((project) => project.isGitRepo).length, unresolved, + importedExcluded, complete: PROJECT_SOURCE_HOSTS.every((host) => sources[host]?.complete !== false), method: PROJECT_SOURCE_METHOD, sources, diff --git a/src/lib/footprint/projects.mjs b/src/lib/footprint/projects.mjs index 1a30578f..87213cc6 100644 --- a/src/lib/footprint/projects.mjs +++ b/src/lib/footprint/projects.mjs @@ -612,7 +612,7 @@ function summarizeCatalog(rows, fsImpl) { const git = statNode(path.join(row.path, '.git'), { fsImpl }); if (git.status !== UNKNOWN && (git.kind === 'dir' || git.kind === 'file')) gitRepos += 1; } - return { everSeen: rows.length, onDisk, gitRepos, unresolved: 0, complete: true }; + return { everSeen: rows.length, onDisk, gitRepos, unresolved: 0, importedExcluded: 0, complete: true }; } /** A `discoverProjectSources()` PAYLOAD rather than a plain catalog array. @@ -684,6 +684,21 @@ function aggregateUnrecognized(rows) { }; } +/** The KPI counts a discovery payload carries; absent fields read as zero, so + * an older payload (no `importedExcluded`) keeps rendering as it did. */ +function payloadCounts(payload) { + return { + everSeen: payload?.everSeen ?? 0, + onDisk: payload?.onDisk ?? 0, + gitRepos: payload?.gitRepos ?? 0, + unresolved: payload?.unresolved ?? 0, + importedExcluded: payload?.importedExcluded ?? 0, + complete: payload?.complete !== false, + method: payload?.method ?? null, + sources: payload?.sources ?? null, + }; +} + /** * Resolve the project catalog `collectProjects` will measure, plus the * ever-seen / on-disk / git-repo counts that ride alongside it. @@ -707,15 +722,7 @@ function resolveProjectCatalog({ projects, sources, discover, fsImpl }) { return { catalog, discoveryProjects: payload?.projects ?? [], - counts: { - everSeen: payload?.everSeen ?? 0, - onDisk: payload?.onDisk ?? 0, - gitRepos: payload?.gitRepos ?? 0, - unresolved: payload?.unresolved ?? 0, - complete: payload?.complete !== false, - method: payload?.method ?? null, - sources: payload?.sources ?? null, - }, + counts: payloadCounts(payload), discoveryReason: null, }; } catch (error) { @@ -797,6 +804,7 @@ function buildProjectsSection({ asOf, out, eligible, selected, excluded, counts, onDisk: kpi(counts?.onDisk ?? 0), gitRepos: kpi(counts?.gitRepos ?? 0), unresolved: counts?.unresolved ?? 0, + importedExcluded: counts?.importedExcluded ?? 0, method: counts?.method ?? null, sources: counts?.sources ?? null, scanned: out.length, diff --git a/src/lib/usage-parsers.mjs b/src/lib/usage-parsers.mjs index 0718cfea..802b4824 100644 --- a/src/lib/usage-parsers.mjs +++ b/src/lib/usage-parsers.mjs @@ -23,6 +23,7 @@ import { normalizeMode } from './usage-modes.mjs'; import { provenanceOf } from './usage-provenance.mjs'; import { promptSemantics } from './usage-prompt-semantics.mjs'; import { observeUsageProject, usageSessionOrigin } from './usage-project-evidence.mjs'; +import { isCodexImportedLine } from './codex-import-marker.mjs'; export { promptSemantics } from './usage-prompt-semantics.mjs'; @@ -1146,18 +1147,6 @@ function processCodexLine(rec, turns, stats, titleState, usageState, latState, m handleCodexEventMsg(rec, turns, stats, titleState, usageState, latState, decoded, rawPayload(e), ms, withTurns, replay); } -/** Codex can import a Claude Code transcript as a thread. The host stamps such - * a rollout's turns `external-import-turn-N` (measured: 796 imports, every one - * carrying it from its first task_started, none of a native thread). The - * in-rollout marker is the signal — the host's imports file is deliberately - * not read, so detection works without it. */ -const CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'; - -function isCodexImportedLine(e) { - const turnId = e?.payload?.turn_id; - return typeof turnId === 'string' && turnId.startsWith(CODEX_IMPORT_TURN_PREFIX); -} - /** The record for an imported rollout: identity only. The conversation's real * prompts, responses and tokens live in the Claude transcript it was imported * from (input_tokens 0, model 'unknown', $0 here), so counting them again as diff --git a/src/lib/usage-project-evidence.mjs b/src/lib/usage-project-evidence.mjs index 2821e886..49918afc 100644 --- a/src/lib/usage-project-evidence.mjs +++ b/src/lib/usage-project-evidence.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import { createHash } from 'node:crypto'; import { inspectProjectIdentity } from './footprint/project-identity.mjs'; import { transcriptSessionOrigin } from './footprint/session-origin.mjs'; +import { isImportedCodexRollout } from './codex-import-marker.mjs'; import { safeProjectLabel } from './live/project-label.mjs'; import { claudeDir, codexDir, opencodeDir, configDir } from './paths.mjs'; @@ -55,8 +56,12 @@ export function observeUsageProject(cwd, { observedAt = Date.now(), cache = CACH return value; } -/** Same bounded head and exact origin allowlists as footprint discovery. */ +/** Same bounded head and exact origin allowlists as footprint discovery. An + * imported Codex copy of a Claude Code transcript declares the ChatGPT desktop + * app as its originator but is not a session from it (ADR-0060 §3). */ export function usageSessionOrigin(raw, host) { const head = Buffer.from(String(raw).slice(0, 256 * 1024)).subarray(0, 256 * 1024).toString('utf8'); - return transcriptSessionOrigin(head.split('\n').filter((line) => line.trim()).slice(0, 40), host); + const lines = head.split('\n').filter((line) => line.trim()).slice(0, 40); + if (host === 'codex' && isImportedCodexRollout(lines)) return { origin: 'unknown', evidence: 'imported-copy' }; + return transcriptSessionOrigin(lines, host); } diff --git a/tests/kit/project-sources-imports.test.mjs b/tests/kit/project-sources-imports.test.mjs new file mode 100644 index 00000000..aef69007 --- /dev/null +++ b/tests/kit/project-sources-imports.test.mjs @@ -0,0 +1,150 @@ +// Imported Codex rollouts (the ChatGPT desktop app's "Import from another +// agent" copies of Claude Code transcripts, turn ids `external-import-turn-N`) +// are not Codex activity: they give a folder no Codex host, no Desktop origin +// and no project, and every scan counts them. ADR-0052 §3, ADR-0060 §3. +// Fixtures live in a temporary folder; nothing reads ~/.codex or ~/.claude. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Rollout } from './helpers/codex-rollout.mjs'; +import { CODEX_IMPORT_TURN_PREFIX, isCodexImportedLine, isImportedCodexRollout } from '../../src/lib/codex-import-marker.mjs'; +import { scanTranscriptCwds, discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; +import { collectProjects } from '../../src/lib/footprint/projects.mjs'; +import { usageSessionOrigin } from '../../src/lib/usage-project-evidence.mjs'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; + +const realpath = (file) => (fs.realpathSync.native ?? fs.realpathSync)(file); + +function fixture(t) { + const root = realpath(fs.mkdtempSync(path.join(os.tmpdir(), 'ak-imported-rollouts-'))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function importedRollout(id, cwd) { + return new Rollout({ id }) + .meta({ cwd, originator: 'Codex Desktop', source: 'vscode', thread_source: undefined }) + .taskStarted(`${CODEX_IMPORT_TURN_PREFIX}-1`).user('imported prompt').agent('imported answer') + .lines; +} + +function nativeRollout(id, cwd, { originator = 'codex_work_desktop', prompt = 'do the thing' } = {}) { + return new Rollout({ id }).meta({ cwd, originator }).turn('gpt-5.6', { cwd }) + .taskStarted('t1').user(prompt).agent('done').lines; +} + +function writeRollout(dir, name, lines) { + const file = path.join(dir, '2026', '09', '27', `rollout-${name}.jsonl`); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${lines.join('\n')}\n`); + return file; +} + +test('the rollout predicate reads only a string payload.turn_id with the import prefix', () => { + assert.equal(isCodexImportedLine({ payload: { turn_id: 'external-import-turn-3' } }), true); + assert.equal(isCodexImportedLine({ payload: { turn_id: 't1' } }), false); + assert.equal(isCodexImportedLine({ payload: { turn_id: 7 } }), false); + assert.equal(isCodexImportedLine(null), false); + assert.equal(isImportedCodexRollout(importedRollout('a', '/p')), true); + assert.equal(isImportedCodexRollout(nativeRollout('b', '/p')), false); + assert.equal(isImportedCodexRollout(['{not json external-import-turn-1', 42, null]), false); + assert.equal(isImportedCodexRollout(null), false); +}); + +test('native rollout that quotes the marker text still counts', () => { + const lines = nativeRollout('c', '/p', { prompt: 'why is my turn id external-import-turn-1?' }); + assert.equal(isImportedCodexRollout(lines), false); +}); + +test('an imported rollout contributes no sighting and is counted; a native Desktop one still is', (t) => { + const root = fixture(t); + const importedDir = path.join(root, 'imported-project'), nativeDir = path.join(root, 'native-project'); + fs.mkdirSync(importedDir); fs.mkdirSync(nativeDir); + const sessions = path.join(root, 'sessions'); + writeRollout(sessions, 'imported', importedRollout('imp', importedDir)); + writeRollout(sessions, 'native', nativeRollout('nat', nativeDir)); + const scan = scanTranscriptCwds(sessions, 'codex'); + assert.deepEqual(scan.sightings.map(({ cwd, sessionOrigin }) => [cwd, sessionOrigin.origin]), + [[nativeDir, 'codex-desktop']]); + assert.equal(scan.importedExcluded, 1); + assert.equal(scan.complete, true, 'setting an import aside is not a gap in the census'); +}); + +test('the counts partition every file', (t) => { + const root = fixture(t), sessions = path.join(root, 'sessions'); + writeRollout(sessions, 'imported', importedRollout('imp', root)); + writeRollout(sessions, 'imported-no-cwd', importedRollout('imp2', undefined)); + writeRollout(sessions, 'native', nativeRollout('nat', root)); + writeRollout(sessions, 'native-no-cwd', new Rollout({ id: 'x' }).raw('event_msg', { type: 'task_started', turn_id: 't1' }).lines); + fs.writeFileSync(path.join(sessions, 'empty.jsonl'), ''); + const scan = scanTranscriptCwds(sessions, 'codex'); + assert.equal(scan.importedExcluded, 2, 'an import without a cwd is still an import'); + assert.equal(scan.withoutCwd, 1); + assert.equal(scan.files, scan.withCwd + scan.withoutCwd + scan.empty + scan.unreadable + scan.importedExcluded); +}); + +test('the Codex marker is never applied to Claude transcripts', (t) => { + const root = fixture(t), projects = path.join(root, 'projects', '-encoded'); + fs.mkdirSync(projects, { recursive: true }); + fs.writeFileSync(path.join(projects, 's.jsonl'), `${JSON.stringify({ + cwd: root, sessionId: 's', entrypoint: 'cli', payload: { turn_id: 'external-import-turn-1' }, + })}\n`); + const scan = scanTranscriptCwds(path.join(root, 'projects'), 'claude'); + assert.equal(scan.sightings.length, 1); + assert.equal(scan.importedExcluded, 0); +}); + +function discover(root, { claudeCwd = null } = {}) { + const claudeRoot = path.join(root, 'claude-projects'); + fs.mkdirSync(path.join(claudeRoot, '-encoded'), { recursive: true }); + if (claudeCwd) { + fs.writeFileSync(path.join(claudeRoot, '-encoded', 's.jsonl'), + `${JSON.stringify({ cwd: claudeCwd, sessionId: 's', entrypoint: 'cli', timestamp: '2026-09-27T00:00:00Z' })}\n`); + } + return discoverProjectSources({ + claudeRoot, codexRoot: path.join(root, 'sessions'), opencodeDbFile: path.join(root, 'absent.db'), + decodeEncodedDirs: false, resolveLabel: (p) => path.basename(p), + }); +} + +test('discovery keeps a folder a Claude transcript names, without a Codex host or Desktop origin', (t) => { + const root = fixture(t), shared = path.join(root, 'shared'); + fs.mkdirSync(shared); + writeRollout(path.join(root, 'sessions'), 'imported', importedRollout('imp', shared)); + const payload = discover(root, { claudeCwd: shared }); + const row = payload.projects.find((p) => p.path === shared); + assert.deepEqual(row.hosts, ['claude']); + assert.deepEqual(row.sessionOrigins.map((o) => o.origin), ['unknown']); + assert.equal(payload.importedExcluded, 1); + assert.equal(payload.complete, true); +}); + +test('an import-only folder is not a project', (t) => { + const root = fixture(t), only = path.join(root, 'only-imported'); + fs.mkdirSync(only); + writeRollout(path.join(root, 'sessions'), 'imported', importedRollout('imp', only)); + const payload = discover(root); + assert.equal(payload.projects.some((p) => p.path === only), false); + assert.equal(payload.everSeen, 0); + assert.equal(payload.importedExcluded, 1); +}); + +test('the Projects section carries the discovery count', () => { + const section = collectProjects({ + sources: { projects: [], everSeen: 0, onDisk: 0, gitRepos: 0, unresolved: 0, importedExcluded: 3, complete: true, method: 'm', sources: {} }, + loc: false, now: () => 1, + }); + assert.equal(section.importedExcluded, 3); + assert.equal(collectProjects({ projects: [], loc: false, now: () => 1 }).importedExcluded, 0); +}); + +test('the usage origin of an imported head is unknown and says why', () => { + const imported = importedRollout('imp', '/p').join('\n'); + assert.deepEqual(usageSessionOrigin(imported, 'codex'), { origin: 'unknown', evidence: 'imported-copy' }); + assert.equal(usageSessionOrigin(nativeRollout('n', '/p').join('\n'), 'codex').origin, 'codex-desktop'); + const parsed = parseCodex(imported, { id: 'imp' }); + assert.equal(parsed.session.imported, true); + assert.equal(parsed.session.sessionOrigin.origin, 'unknown'); +}); From a76738a4dcdecd1d7158104207e6006f8a1ae51c Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 08:53:24 -0700 Subject: [PATCH 03/15] feat(system): say how many imported Codex copies project discovery set aside Failing first: the new system-summary test failed on the missing sentence (the note ended at "60 are git repos."); passing after: 9/9 in tests/kit/system-summary.test.mjs. An old snapshot without importedExcluded, or a zero, renders exactly as before. --- src/lib/dashboard/client/system-readout.mjs | 7 +++++++ tests/kit/system-summary.test.mjs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/dashboard/client/system-readout.mjs b/src/lib/dashboard/client/system-readout.mjs index 1d8d7ccb..bad963e4 100644 --- a/src/lib/dashboard/client/system-readout.mjs +++ b/src/lib/dashboard/client/system-readout.mjs @@ -381,6 +381,13 @@ import { fmtNum, fmtTok } from './usage.mjs'; note+=" "+esc(fmtNum(p.unresolved))+" path"+(p.unresolved===1?"":"s") +" could not be decoded, so both counts are floors."; } + var imported=typeof p.importedExcluded==="number"&&p.importedExcluded>0?p.importedExcluded:0; + if(imported){ + note+=" "+esc(fmtNum(imported))+(imported===1 + ?" Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted" + :" Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted") + +"; the Claude Code transcript already names its folder."; + } if(p.truncated)note+=" The measured list is capped, so fewer rows than on-disk projects."; return note; } diff --git a/tests/kit/system-summary.test.mjs b/tests/kit/system-summary.test.mjs index 393e0a34..d6808007 100644 --- a/tests/kit/system-summary.test.mjs +++ b/tests/kit/system-summary.test.mjs @@ -232,6 +232,23 @@ test('the KPI band and every catalog card render identically from the summary an assert.deepEqual(fromSummary, fromFull); }); +test('the projects note says how many imported copies discovery set aside, and nothing when there are none', () => { + const noteFor = (extra) => { + const client = systemClient(); + const projects = { everSeen: { value: 114 }, onDisk: { value: 90 }, gitRepos: { value: 60 }, unresolved: 0, + method: 'm', projects: [], ...extra }; + client.readout.renderSysKpis({ ...fullPayload(1), projects }); + return client.document.getElementById('sys-kpis-note').innerHTML; + }; + const note = noteFor({ importedExcluded: 924 }); + assert.match(note, /924 Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted/); + assert.match(noteFor({ importedExcluded: 1 }), /1 Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted/); + for (const extra of [{}, { importedExcluded: 0 }]) { + const plain = noteFor(extra); + assert.doesNotMatch(plain, /imported|undefined/, 'an old snapshot or a zero renders exactly as before'); + } +}); + // ── The page reads the slim endpoint ──────────────────────────────────────── test('loadSystem fetches /api/system/summary, deep refresh parameters included', async () => { From 7e73a324ec57c22b29a3b2aa96d9c9bd17a9590e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 08:55:17 -0700 Subject: [PATCH 04/15] docs(adr): record that discovery excludes imported copies (ADR-0052) ADR-0052 and ADR-0060 get Updated lines, and ADR-0060's implementation status now says that section 3 is implemented for discovery. The DDD machine-footprint, DASHBOARD, MAINTENANCE and TRANSCRIPTS docs describe the exclusion and the importedExcluded count as current state. The marker move shifted usage-parsers.mjs lines, so three file:line citations are re-pointed at their subjects: USAGE-SCORECARD-METRICS.md (1185 to 1174, and 1008-1013 to 1021-1025 in both places) and TRANSCRIPTS.md (758-763 to 770-773). Before this commit tests/kit/doc-citations.test.mjs failed 2 of 8 on that drift. After it, doc-citations and ga-surface-guard pass 8/8 and markdownlint-cli2 reports 0 issues. --- docs/DASHBOARD.md | 4 +++- docs/MAINTENANCE.md | 5 +++-- docs/TRANSCRIPTS.md | 7 +++++-- docs/USAGE-SCORECARD-METRICS.md | 6 +++--- docs/adr/0052-codex-usage-attribution.md | 8 ++++++++ ...0060-session-surface-initiator-and-product-names.md | 10 ++++++++-- docs/ddd/machine-footprint.md | 9 ++++++--- 7 files changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index cda3b808..ca00fa6e 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -639,7 +639,9 @@ session." A session cwd is only a discovery candidate; it does not confer project scope. If that cwd makes a candidate surface resolve to the same host, kind, and path as a user surface, the user occurrence wins and no project-pressure row is fabricated. One shared user surface carried by two hosts remains -visible on both hosts. +visible on both hosts. A Codex session that the ChatGPT desktop app imported from a Claude Code +transcript is not a candidate at all: it adds no project, host or Desktop origin, and the Projects +note under the System KPIs says how many were set aside. The measurement counts a physical artifact once and retains each host that discovers it as a separate consumer binding. It fingerprints bounded skill definitions and individual MCP diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 5dfb73ab..746766ef 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -108,8 +108,9 @@ Project choices group beneath repositories only when shared Git metadata or the contract establishes the association. A worktree remains its own selectable project. Folder/name headers, a Git/Worktree/Folder designation, and all detected language icons identify each choice. An independent **Session origin** facet can match Claude Desktop, Codex Desktop, or unclassified -observations; Desktop origin never replaces repository membership. Facet counts are installations -in matching projects, not numbers of sessions. Selecting several origins does not duplicate a +observations; Desktop origin never replaces repository membership. Codex copies of Claude Code +sessions imported by the ChatGPT desktop app never count as a Desktop origin. Facet counts are +installations in matching projects, not numbers of sessions. Selecting several origins does not duplicate a placement. Exact root paths stay private; the public grouping uses opaque repository identities and bounded display labels. diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 43568ba6..642ffda0 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -75,7 +75,7 @@ Each line has a top-level `type`. The parser (`parseClaude`, | "ai-title" | The model-written session title (`usage-parsers.mjs:768`) — preferred over the first-prompt fallback | | `user` | A user-**role** turn — which is *not* the same as "the human"; see §3. On a turn that passes isHumanPrompt, also its `permissionMode` — the session's permission posture, read on the person's own turn only (`usage-parsers.mjs:593-609`) — and the opening of the response-latency window | | `assistant` | One content block of a model message: `model` id, the message's `usage` token counts (repeated on every block's line, so counted once per `message.id`, else `requestId`, last line winning), `tool_use` blocks (`usage-parsers.mjs:661-743`) | -| any | Side-band fields read regardless of type: `attributionSkill`/`attributionPlugin` (`usage-parsers.mjs:758-759`), `isSidechain` (`usage-parsers.mjs:760-763`), `cwd` for project derivation | +| any | Side-band fields read regardless of type: `attributionSkill`/`attributionPlugin` (`usage-parsers.mjs:770-771`), `isSidechain` (`usage-parsers.mjs:772-773`), `cwd` for project derivation | A real assistant completion also closes two pieces of per-entry evidence the transcript does not state outright. It **closes the latency window** the @@ -139,7 +139,10 @@ Three rollout shapes are handled before any of that: no `thread_source`, `input_tokens: 0`). The parser stops at the first such line and returns an identity-only record marked `imported`; the scan keeps it out of Codex sessions, prompts, responses and yield diagnostics and counts it in - `importedExcluded`. The real conversation is in the Claude transcript. + `importedExcluded`. The real conversation is in the Claude transcript. Project + discovery reads the same marker in each rollout's head and leaves the rollout out + of projects, hosts and Desktop origins, counting it in the discovery scan's + `importedExcluded`. * **Forked subagents.** A forked subagent's rollout replays its parent's history before its own turns. Every envelope carries an `ordinal`; the replay ends at `subagent_history_start_ordinal` when some event lies at or beyond it, else at the diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index e02c0d25..3963f95d 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -199,7 +199,7 @@ responses = Σ over included sessions of session.responses - `responses` accumulation: Claude increments once per API message id — every transcript line of one message counts once, the last line's usage winning (`usage-parsers.mjs:661-696`); Codex increments per `agent_message` event - (`usage-parsers.mjs:1008-1013`). + (`usage-parsers.mjs:1021-1025`). - Totals: `totals.responses += s.responses` per included session (`usage-aggregate.mjs:928`). - Render: `kpi("sessions", fmtNum(t.sessions), fmtNum(t.responses)+" assistant @@ -836,7 +836,7 @@ byDay[day].sessionsActive = count of distinct sessions with any usage row that d **Source:** the day key is the row's own `row.day`, computed once at parse time as **local calendar day**, not UTC -(`usage-parsers.mjs:35`/`usage-parsers.mjs:1185` call `localDay(at)`) — so a +(`usage-parsers.mjs:35`/`usage-parsers.mjs:1174` call `localDay(at)`) — so a session that runs from 23:58 local to 00:05 local has its session count attributed to the day its *first* usage row landed on (test: `tests/kit/usage-index.test.mjs:738`, "a session that opens before midnight @@ -926,7 +926,7 @@ punchcard[dow + "-" + hour] += 1 per assistant/agent_message response, at its **Source:** incremented once per Claude API message (all of a message's transcript lines are one hit) (`usage-parsers.mjs:42`, keyed by this call: `punchKey(at)`) and once per Codex -`agent_message` (`usage-parsers.mjs:1008-1013`), merged into the window-level +`agent_message` (`usage-parsers.mjs:1021-1025`), merged into the window-level `punchcard` object per session (`usage-aggregate.mjs:943-1007`). Cell intensity is linear against the single busiest cell in the window: `v = pcMax ? n/pcMax : 0` (`dashboard/client.mjs`) — this is a diff --git a/docs/adr/0052-codex-usage-attribution.md b/docs/adr/0052-codex-usage-attribution.md index 3e5f58c8..589ac381 100644 --- a/docs/adr/0052-codex-usage-attribution.md +++ b/docs/adr/0052-codex-usage-attribution.md @@ -2,6 +2,9 @@ - **Status:** Accepted - **Date:** 2026-09-19 +- **Updated:** 2026-09-27 — imported copies are also excluded from project discovery: they give no + project, host or Desktop origin, and the discovery scan counts them in `importedExcluded` + (ADR-0060 §3). The marker now lives in one leaf module shared by usage and discovery. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0009](0009-usage-scorecard-local-transcript-analytics.md), [ADR-0038](0038-consistent-cross-host-session-metrics.md), @@ -117,6 +120,11 @@ out of aggregation, out of every yield statistic, and counted in `diagnostics.importedExcluded` (796 on the reference machine). Nothing is dropped silently. The record itself is still cached, so a rescan is cheap. +Project discovery applies the same marker to each rollout's bounded head (256 KiB, 40 lines): an +imported copy names no project, host or Desktop origin, and the scan reports how many it set aside +(`importedExcluded`, 924 on the reference machine on 2026-09-27, every marker on the rollout's +second line). + ### 4. Cumulative counter restarts are summed, per event `total_token_usage` restarts from zero mid-file: 48 restarts across 33 files diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index ba844a9c..41bbc57a 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -1,7 +1,10 @@ # ADR-0060 — Session surface, initiator and official product names -- **Status:** Proposed (staged follow-on; nothing implemented) +- **Status:** Proposed (staged follow-on) - **Date:** 2026-09-26 +- **Updated:** 2026-09-27 — §3 implemented for project discovery and the System projects note: + imported copies give no project, host or origin and are counted. The ledger-derived source labels + (Cursor, Cowork) and the other views remain proposed. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0050](0050-dashboard-project-identity-and-context-reporting.md) (session origin rule, superseded in part by this record once accepted), @@ -182,4 +185,7 @@ fixture. ## Implementation status -Not started. Staged as follow-on work in the audit record's Addendum 3. +§3 is implemented for project discovery and the System projects note (2026-09-27): an imported copy +gives no project, host or origin, and discovery counts it in `importedExcluded`. The per-source +labels from the imports ledger, Runtime attribution and §1, §2 and §4–§6 remain follow-on work +(the audit record's Addendum 3). diff --git a/docs/ddd/machine-footprint.md b/docs/ddd/machine-footprint.md index cb3eb1a3..b732f7af 100644 --- a/docs/ddd/machine-footprint.md +++ b/docs/ddd/machine-footprint.md @@ -644,11 +644,14 @@ declarations and unclassified sightings. `countBasis` distinguishes transcript f sessions, recovered-project sightings and mixed observations; a recovered directory is not one verified session. +Imported session copies are not sightings. A Codex rollout stamped `external-import-turn-*` is a +Claude Code transcript that the ChatGPT desktop app imported; the Claude transcript already names +its folder. Discovery skips it before reading its cwd, so it adds no project, host or Session +origin, and counts it in `importedExcluded` (per host scan and in total). `complete` is unaffected. + **Proposed change ([ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md)).** `sessionOrigins` is to be replaced by session surface and initiator, derived from the same declared -fields but keeping every raw value. Imported session copies (Codex rollouts stamped -`external-import-turn-*`) are to contribute no project sighting: on 2026-09-26 they gave 23 project -folders a Desktop origin they never had. +fields but keeping every raw value. `project-identity.mjs` relates directories through canonical Git metadata and, for linked worktrees, a verified common directory plus backlink. It preserves unknown association when evidence is From bcdf9f897d26527e8988eba51adc3c6f00f891d7 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:03:52 -0700 Subject: [PATCH 05/15] fix(system): stop claiming a Claude transcript names every imported copy's folder A folder that only an imported Codex copy names has no Claude Code transcript, and discovery drops it from everSeen. The KPI note and the DDD doc now say the copies are not counted and the original Claude Code session is counted where its transcript still exists; the note agrees in number for one copy. --- docs/ddd/machine-footprint.md | 5 +++-- src/lib/dashboard/client/system-readout.mjs | 6 +++--- tests/kit/system-summary.test.mjs | 7 ++++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/ddd/machine-footprint.md b/docs/ddd/machine-footprint.md index b732f7af..4bd97da5 100644 --- a/docs/ddd/machine-footprint.md +++ b/docs/ddd/machine-footprint.md @@ -645,8 +645,9 @@ sessions, recovered-project sightings and mixed observations; a recovered direct verified session. Imported session copies are not sightings. A Codex rollout stamped `external-import-turn-*` is a -Claude Code transcript that the ChatGPT desktop app imported; the Claude transcript already names -its folder. Discovery skips it before reading its cwd, so it adds no project, host or Session +Claude Code transcript that the ChatGPT desktop app imported; it is a copy, and the original Claude +Code session is counted where its transcript still exists. A folder that only an import names is +therefore not a project. Discovery skips it before reading its cwd, so it adds no project, host or Session origin, and counts it in `importedExcluded` (per host scan and in total). `complete` is unaffected. **Proposed change ([ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md)).** diff --git a/src/lib/dashboard/client/system-readout.mjs b/src/lib/dashboard/client/system-readout.mjs index bad963e4..3afaf472 100644 --- a/src/lib/dashboard/client/system-readout.mjs +++ b/src/lib/dashboard/client/system-readout.mjs @@ -384,9 +384,9 @@ import { fmtNum, fmtTok } from './usage.mjs'; var imported=typeof p.importedExcluded==="number"&&p.importedExcluded>0?p.importedExcluded:0; if(imported){ note+=" "+esc(fmtNum(imported))+(imported===1 - ?" Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted" - :" Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted") - +"; the Claude Code transcript already names its folder."; + ?" Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted; it is a copy" + :" Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted; they are copies") + +", and the original Claude Code session is counted where its transcript still exists."; } if(p.truncated)note+=" The measured list is capped, so fewer rows than on-disk projects."; return note; diff --git a/tests/kit/system-summary.test.mjs b/tests/kit/system-summary.test.mjs index d6808007..5b76ea72 100644 --- a/tests/kit/system-summary.test.mjs +++ b/tests/kit/system-summary.test.mjs @@ -242,7 +242,12 @@ test('the projects note says how many imported copies discovery set aside, and n }; const note = noteFor({ importedExcluded: 924 }); assert.match(note, /924 Codex copies of Claude Code sessions, imported by the ChatGPT desktop app, are not counted/); - assert.match(noteFor({ importedExcluded: 1 }), /1 Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted/); + assert.match(note, /are not counted; they are copies, and the original Claude Code session is counted where its transcript still exists\./); + const one = noteFor({ importedExcluded: 1 }); + assert.match(one, /1 Codex copy of a Claude Code session, imported by the ChatGPT desktop app, is not counted; it is a copy, and the original Claude Code session is counted where its transcript still exists\./); + for (const text of [note, one]) { + assert.doesNotMatch(text, /already names its folder/, 'an import-only folder has no Claude transcript naming it'); + } for (const extra of [{}, { importedExcluded: 0 }]) { const plain = noteFor(extra); assert.doesNotMatch(plain, /imported|undefined/, 'an old snapshot or a zero renders exactly as before'); From c5f4e7018723869d919f87be1082fbd0f6945476 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:04:05 -0700 Subject: [PATCH 06/15] test(usage): prove the Codex import marker never reclassifies a Claude transcript usageSessionOrigin is also called with 'claude'. A Claude head whose line carries payload.turn_id external-import-turn-1 keeps its declared claude-desktop origin; removing the host === 'codex' guard now fails this test. --- tests/kit/project-sources-imports.test.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/kit/project-sources-imports.test.mjs b/tests/kit/project-sources-imports.test.mjs index aef69007..d0353212 100644 --- a/tests/kit/project-sources-imports.test.mjs +++ b/tests/kit/project-sources-imports.test.mjs @@ -148,3 +148,10 @@ test('the usage origin of an imported head is unknown and says why', () => { assert.equal(parsed.session.imported, true); assert.equal(parsed.session.sessionOrigin.origin, 'unknown'); }); + +test('the usage origin never applies the Codex marker to a Claude transcript', () => { + const claudeHead = JSON.stringify({ + cwd: '/p', sessionId: 's', entrypoint: 'claude-desktop', payload: { turn_id: 'external-import-turn-1' }, + }); + assert.deepEqual(usageSessionOrigin(claudeHead, 'claude'), { origin: 'claude-desktop', evidence: 'entrypoint:claude-desktop' }); +}); From 5d940a924f8d82b5c80e979832633f1e96af4227 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:22:38 -0700 Subject: [PATCH 07/15] fix(system): retire v7 snapshots that still hold imported-copy origins A footprint snapshot written before discovery set imported Codex copies aside keeps their false Codex host and Desktop origin on project rows, and the Maintenance session-origin facet reads those rows until a manual deep scan. The schema advances to v8 so a v7 snapshot reads as unmeasured instead of being shown under the new rule; UPGRADING says to run Full scan. --- docs/UPGRADING.md | 9 +++++++++ src/lib/footprint/snapshot.mjs | 6 ++++-- tests/kit/footprint-snapshot-v2.test.mjs | 8 ++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 3905e5cc..56bc7c77 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -39,6 +39,15 @@ and supported `claude mcp serve` tool exposure are preserved. See [ADR-0051](adr/0051-supported-peer-delegation-and-host-realignment.md) for the policy, official source citations, authority boundaries and verification limits. +## 2026-09-27: System snapshot v8 (imported Codex copies) + +When the ChatGPT desktop app imports a Claude Code transcript, it saves a copy as a Codex session. +Project discovery no longer counts these copies: they give a folder no Codex host and no Desktop +origin, and System says how many it set aside. A snapshot taken before this change still holds the +old hosts and origins, so the Footprint snapshot schema advances to v8. This build reports a v7 +snapshot as unreadable until you run **Full scan** in System or `ak system --deep`. It is never +shown under the new rule. See [ADR-0060](adr/0060-session-surface-initiator-and-product-names.md) §3. + ## 2026-09-26: `ak sync`'s exit code ignores fixes you do by hand `ak sync` now exits 0 when everything it can repair has converged, even if a row whose fix you diff --git a/src/lib/footprint/snapshot.mjs b/src/lib/footprint/snapshot.mjs index 6d91a228..e0d0367d 100644 --- a/src/lib/footprint/snapshot.mjs +++ b/src/lib/footprint/snapshot.mjs @@ -26,8 +26,10 @@ import { CARRIED_FORWARD, MEASURED } from './walk.mjs'; /** Bump when a section's persisted SHAPE changes incompatibly. A snapshot * written by a different version is not migrated and not guessed at — it * reads as never-measured with an explicit reason, and the next deep scan - * replaces it. */ -export const SNAPSHOT_SCHEMA_VERSION = 7; + * replaces it. v8: project rows no longer take a host or Desktop origin from + * Codex copies of Claude Code transcripts (ADR-0060 §3), so a v7 row's + * origins cannot be trusted. */ +export const SNAPSHOT_SCHEMA_VERSION = 8; /** The deep-tier sections, in collection order. This list is also the write * filter — see the header note on the runtime census. A section absent from a diff --git a/tests/kit/footprint-snapshot-v2.test.mjs b/tests/kit/footprint-snapshot-v2.test.mjs index 9d634ae6..0e8af989 100644 --- a/tests/kit/footprint-snapshot-v2.test.mjs +++ b/tests/kit/footprint-snapshot-v2.test.mjs @@ -8,13 +8,13 @@ import { readSnapshot, SNAPSHOT_SCHEMA_VERSION, writeSnapshot, } from '../../src/lib/footprint/snapshot.mjs'; -test('hosted-project population evidence invalidates earlier footprint snapshots instead of guessing', (t) => { +test('discovery that sets imported Codex copies aside invalidates earlier footprint snapshots instead of guessing', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-footprint-snapshot-v3-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const file = path.join(root, 'snapshot.json'); - assert.equal(SNAPSHOT_SCHEMA_VERSION, 7); - for (const schemaVersion of [1, 2, 3, 4, 5, 6]) { + assert.equal(SNAPSHOT_SCHEMA_VERSION, 8); + for (const schemaVersion of [1, 2, 3, 4, 5, 6, 7]) { fs.writeFileSync(file, `${JSON.stringify({ schemaVersion, asOf: 10, sections: { catalog: {} } })}\n`); const old = readSnapshot({ file }); assert.equal(old.present, false); @@ -25,6 +25,6 @@ test('hosted-project population evidence invalidates earlier footprint snapshots assert.equal(written.ok, true); const current = readSnapshot({ file }); assert.equal(current.present, true); - assert.equal(current.schemaVersion, 7); + assert.equal(current.schemaVersion, 8); assert.equal(current.sections.catalog.asOf, 20); }); From f43f106a0187d62e851dcd19effd8b21c6e3d085 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:23:00 -0700 Subject: [PATCH 08/15] docs(adr): note that imported Codex copies give no session origin (ADR-0050) ADR-0050 mapped the Codex Desktop originator to the Desktop origin without exception; discovery now skips imported copies, so the rule says so. --- ...hboard-project-identity-and-context-reporting.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/adr/0050-dashboard-project-identity-and-context-reporting.md b/docs/adr/0050-dashboard-project-identity-and-context-reporting.md index 37b79a0c..61878c55 100644 --- a/docs/adr/0050-dashboard-project-identity-and-context-reporting.md +++ b/docs/adr/0050-dashboard-project-identity-and-context-reporting.md @@ -2,9 +2,13 @@ - **Status:** Implemented - **Date:** 2026-09-09 -- **Updated:** 2026-09-26 — the `project-concentration` Finding ranks `gitProjects`, not the - folder-label `byProject`, so it agrees with Score → Projects (#238 item 7) -- **Earlier update:** 2026-09-09 — scoped context-card network claims for issue #211 +- **Updated:** 2026-09-27 — a Codex rollout the ChatGPT desktop app imported from a Claude Code + transcript gives no session origin, even though it declares `Codex Desktop` + ([ADR-0052](0052-codex-usage-attribution.md) §3, + [ADR-0060](0060-session-surface-initiator-and-product-names.md) §3) +- **Earlier updates:** 2026-09-26 — the `project-concentration` Finding ranks `gitProjects`, not + the folder-label `byProject`, so it agrees with Score → Projects (#238 item 7); 2026-09-09 — + scoped context-card network claims for issue #211 - **Related:** [ADR-0036](0036-dashboard-client-modularization-and-shared-loopback-server.md), [ADR-0048](0048-inventory-led-maintenance-resource-management.md) @@ -35,6 +39,9 @@ Session origin comes from bounded transcript metadata, separate from the existin - Codex `session_meta.originator`: `Codex Desktop`, `codex_work_desktop`. - Everything else, including ambiguous SDK and VS Code markers: unknown. +A Codex rollout whose turns carry the `external-import-turn` marker is a copy of a Claude Code +transcript, not a Codex session. It supplies no origin, whatever its `originator` says. + These are source declarations, not attestation of the initiating application. Claude's installed 2.1.266 runtime maps those entrypoints to Claude Desktop; Codex values were observed in local bounded session metadata. No prompt content, From 55288077f4f50a22d8e8aca49b3e4347f2273f20 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:23:28 -0700 Subject: [PATCH 09/15] =?UTF-8?q?docs(adr):=20say=20ADR-0060=20=C2=A73=20i?= =?UTF-8?q?s=20implemented=20for=20project=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status, the ADR index blurb and the glossary header still read as if nothing were implemented. The acceptance decision stays with the maintainer, so the status names the implemented part without saying Accepted. The consequence now gives the re-measured 32 folders. --- .../0060-session-surface-initiator-and-product-names.md | 7 ++++--- docs/adr/README.md | 9 +++++---- docs/ddd/ubiquitous-language.md | 9 +++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index 41bbc57a..a45d8fb1 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -1,6 +1,6 @@ # ADR-0060 — Session surface, initiator and official product names -- **Status:** Proposed (staged follow-on) +- **Status:** Proposed; §3 implemented for project discovery (2026-09-27), the rest staged follow-on - **Date:** 2026-09-26 - **Updated:** 2026-09-27 — §3 implemented for project discovery and the System projects note: imported copies give no project, host or origin and are counted. The ledger-derived source labels @@ -163,8 +163,9 @@ in full. ## Consequences - Usage, System → Projects, Maintenance facets, Intelligence designation (which today mixes Git - scope, origin and host in one enum) and the Runtime table change labels and counts. Twenty-three - project folders lose a false Desktop origin on this machine. + scope, origin and host in one enum) and the Runtime table change labels and counts. On this + machine 32 project folders lost a false Desktop origin when discovery began setting imports aside + (re-measured 2026-09-27; 23 on 2026-09-26). - The usage cache schema changes (new session fields); a rebuild is expected. - Tests that pin current names change together (inventory in the audit record, Addendum 3). - `CLAUDE_CODE_ENTRYPOINT` and the transcript format are internal to Claude Code and may change; diff --git a/docs/adr/README.md b/docs/adr/README.md index e7ecc6af..b6462e71 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -391,7 +391,8 @@ component out. ## ADR-0060 — Session surface, initiator and official product names -[ADR-0060](0060-session-surface-initiator-and-product-names.md) (Proposed) derives a session's -surface and initiator from the hosts' declared log fields, keeps every raw value, uses official -product names (Claude Desktop, ChatGPT desktop app, Codex CLI, and others), and excludes imported -session copies from every origin view. +[ADR-0060](0060-session-surface-initiator-and-product-names.md) (Proposed; §3 implemented for +project discovery) derives a session's surface and initiator from the hosts' declared log fields, +keeps every raw value, uses official product names (Claude Desktop, ChatGPT desktop app, Codex CLI, +and others), and excludes imported session copies from every origin view. Project discovery already +sets imported copies aside and counts them; the other decisions remain proposed. diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index cb9e00fc..8a64c186 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -121,11 +121,12 @@ missing price. `Dual-host` describes two enabled peer hosts, not an execution command and not evidence that two inference vendors served a workflow. Generalized execution belongs to `ak run`. -## Session surface language (proposed) +## Session surface language (mostly proposed) -These terms are proposed by [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md) -and not yet implemented. Until they are, the implemented contract is ADR-0050's **session origin** -(`claude-desktop`, `codex-desktop` or `unknown`). +These terms are proposed by [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md). +Only **Imported session copy** is implemented so far, and only in usage and project discovery. For +the rest, the implemented contract is ADR-0050's **session origin** (`claude-desktop`, +`codex-desktop` or `unknown`), which an imported copy never supplies. | Term | Meaning | |------|---------| From 460335bd374b1f5670635ef1445fe1bf3128220b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:23:37 -0700 Subject: [PATCH 10/15] docs(adr): list the views that still omit the imported-copy count (ADR-0060) The Intelligence census line, the System Projects liner and the ak system text output show the reduced project counts without saying why. --- ...0060-session-surface-initiator-and-product-names.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index a45d8fb1..a81b102e 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -190,3 +190,13 @@ fixture. gives no project, host or origin, and discovery counts it in `importedExcluded`. The per-source labels from the imports ledger, Runtime attribution and §1, §2 and §4–§6 remain follow-on work (the audit record's Addendum 3). + +Three views already show the smaller project counts but do not yet say how many imported copies were +set aside; §3's "each view reports how many it excluded" is still owed for them: + +- the Intelligence census line (`src/lib/dashboard/client/intelligence.mjs`, which prints + `everSeen`; the server's `readCensus` in `src/lib/dashboard-server.mjs` drops `importedExcluded`); +- the System → Projects liner (`sysProjectsLinerHtml` in + `src/lib/dashboard/client/system-projects.mjs`); +- the `ak system` text output (`renderProjects` in `src/commands/system.mjs`, which prints only the + count; `ak system --json` carries `importedExcluded`). From 75265ca86e2f40b04f966b21687c27aea599f39b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:23:51 -0700 Subject: [PATCH 11/15] docs(plan): cite the snapshot rows the Maintenance origin facet reads The Branch 1 plan cited the Intelligence census for the Maintenance Session origin facet; the facet reads footprint snapshot rows, so it is correct only after a Full scan. --- .../plans/2026-09-27-branch-1-imported-rollout-origins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md b/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md index e4563f9c..ac7e7eb3 100644 --- a/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md +++ b/docs/superpowers/plans/2026-09-27-branch-1-imported-rollout-origins.md @@ -28,7 +28,7 @@ | Discovery records a sighting and a Desktop origin for imported rollouts | **Confirmed** | `src/lib/footprint/project-sources.mjs:304` pushes a sighting for every rollout with a cwd; `transcriptSessionOrigin` (`src/lib/footprint/session-origin.mjs:11-17`) maps `originator: "Codex Desktop"` to `codex-desktop` without looking at turn ids. | | Size of the effect on the maintainer's machine | **Confirmed, numbers moved** | Read-only count on 2026-09-27: 1,692 rollouts; 924 imported, all with `originator: "Codex Desktop"`, all with a cwd, and in 924 of 924 the marker is on head line 2 (the first `event_msg/task_started`). 33 folders are named by an import; **32** show a Desktop origin only because of imports; **25** lose the Codex host entirely; **5** are named by nothing but an import and leave `everSeen` (114 today). The spec's 874 / 23 (2026-09-26) are the earlier measurement. One further file mentions the marker only in message text, not as a `turn_id`. | | `usageSessionOrigin` labels imports `codex-desktop` | **Confirmed at the function, already neutral in effect** | `src/lib/usage-project-evidence.mjs:59-62` classifies the head without the marker, and `parseCodex` sets `rec.sessionOrigin` (`src/lib/usage-parsers.mjs:1230`) before stopping at the first imported line (`:1245`). But `recordCodexCandidate` counts the import and returns false (`src/lib/usage-index.mjs:698`) and `:920` keeps `session.imported` out of `records`, so no usage view ever shows that origin. The change here is for consistency and any future caller; it has no visible effect and needs no usage cache schema bump. | -| Maintenance "Session origin" facet | **Fixed by this branch with no extra code** | `src/lib/project-census.mjs:142` reads each project's `sessionOrigins`, which discovery builds. | +| Maintenance "Session origin" facet | **Fixed by this branch, after a Full scan** | The facet reads the footprint snapshot's project rows (`src/lib/maintenance/management/projection.mjs:1083-1084` passes `footprint.projects.projects` and `discoveryProjects` to `enrichProjectPresentation`; `src/lib/maintenance/management/projection-projects.mjs:74` copies each row's `sessionOrigins`), and discovery builds those origins. A snapshot taken before this branch keeps the old origins, so the snapshot schema advances to v8 and the facet is correct once a Full scan writes a new one. `src/lib/project-census.mjs:142` is the Intelligence picker's `learningOrigins`, which the live census fixes directly. | | Usage project groups (`usage-project-groups.mjs:4-6`) | **Already correct** | Built from `records`, which never contain imports. | | The shared predicate "beside `CODEX_IMPORT_TURN_PREFIX` in `usage-parsers.mjs`" | **Placement changed (flagged)** | `usage-parsers.mjs:25` imports `usage-project-evidence.mjs`; `usage-project-evidence.mjs` does not reach `usage-parsers.mjs` today (checked by walking the import graph). Exporting the predicate from `usage-parsers.mjs` and importing it into `usage-project-evidence.mjs` would add a new import cycle. The plan moves the one constant and the existing `isCodexImportedLine` into a leaf module `src/lib/codex-import-marker.mjs` that all three modules import. Still one constant; the existing predicate is reused, not copied. | | Storage attribution of rollout bytes to projects | **Out of scope** | `src/lib/footprint/storage.mjs:57,612` uses `transcriptMetadata` to attribute transcript bytes; those bytes are real disk use. Runtime and storage attribution belong to ADR-0060 Branch 7. | From 0b2976dd7145190aaa1b63b811fc86c6c204d4cb Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:24:00 -0700 Subject: [PATCH 12/15] docs(ddd): note that imported Codex copies supply no Desktop origin The Maintenance and Project Intelligence models still said every exact Desktop declaration adds an origin membership or picker suffix. --- docs/ddd/maintenance.md | 3 ++- docs/ddd/project-intelligence.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ddd/maintenance.md b/docs/ddd/maintenance.md index b1926584..6ae305e3 100644 --- a/docs/ddd/maintenance.md +++ b/docs/ddd/maintenance.md @@ -129,7 +129,8 @@ bounded label, evidence class and observation time; raw roots and common-directo private. Discovery's existing repository-key relationships retain their own provenance. Session origin is an independent overlapping project facet. Exact Claude/Codex Desktop declarations -supply memberships; all other observations remain unclassified. Selecting either or both Desktop +supply memberships, except in a Codex rollout imported from a Claude Code transcript, which supplies +none (ADR-0060 §3); all other observations remain unclassified. Selecting either or both Desktop origins filters distinct placements in matching projects once. Focus node counts remain installation counts, and session-origin counts are copied once per project rather than summed per installed resource. Encoded-directory recovery is labelled a recovered-project sighting, not a verified diff --git a/docs/ddd/project-intelligence.md b/docs/ddd/project-intelligence.md index 9dfd3fef..0fba75c0 100644 --- a/docs/ddd/project-intelligence.md +++ b/docs/ddd/project-intelligence.md @@ -103,7 +103,8 @@ zero patterns can say *why* it reports zero rather than being indistinguishable The native select groups choices as Git repositories, Git worktrees, User-level learning, and Other / unclassified, alphabetically within each group. Grouping uses `learningScope` and its evidence; only exact configured user-state roots qualify as user-level. Known Desktop declarations -add Claude Desktop/Codex Desktop suffixes independently. `learningOrigins` is a union when census +add Claude Desktop/Codex Desktop suffixes independently; a Codex rollout imported from a Claude Code +transcript adds none, because discovery sets it aside (ADR-0060 §3). `learningOrigins` is a union when census rows fold to one identity. Sorting does not change selection keys or machine-wide totals. The picker remains visible when the selected project has no history, so the user can choose another. From ee610862ac2537395fc0201cf7c69d18485eb388 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:24:11 -0700 Subject: [PATCH 13/15] docs(adr): record later non-import turns inside imported copies as open Six of 924 imported rollouts hold a later turn with real token usage that whole-rollout exclusion drops. ADR-0052 records it as not done and ADR-0060 lists the counting question for acceptance. --- docs/adr/0052-codex-usage-attribution.md | 8 ++++++++ .../0060-session-surface-initiator-and-product-names.md | 3 +++ 2 files changed, 11 insertions(+) diff --git a/docs/adr/0052-codex-usage-attribution.md b/docs/adr/0052-codex-usage-attribution.md index 589ac381..28e63445 100644 --- a/docs/adr/0052-codex-usage-attribution.md +++ b/docs/adr/0052-codex-usage-attribution.md @@ -204,6 +204,14 @@ subagent and previously dropped usage is now priced. - A subagent with no ordinals still reports no usage (decision 2). - One rollout carries `token_count`s but no agent message, so the pre-existing `partial-response-yield` warning remains. +- Whole-rollout exclusion may drop real usage (open, plausible, 2026-09-27). On the reference + machine 6 of 924 imported rollouts carry a later turn that is not an import: one `task_started` + whose `turn_id` starts with `rollout-`, no `user_message` event, `role: user` response items in + five of the six (2 to 76 per file) and non-zero `token_count` usage (the per-file sum of + `last_token_usage.total_tokens` is about 8k to 449k). Both usage and discovery set the whole file + aside at the marker, so this usage is not counted. With no `user_message`, the turn may be + automatic (a compaction or title pass). Measured from counts only; whether to count such turns is + undecided (see ADR-0060 open questions). ## Verification diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index a81b102e..77bb6c07 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -177,6 +177,9 @@ in full. - Whether "Cloud session" should appear at all in local views, given none was observed locally. - Whether the "on 3P" attribute is worth showing. - How ADR-0057's role lenses consume surface and initiator. +- Whether a later turn inside an imported copy that is not itself an import (6 of 924 rollouts on + 2026-09-27, with real token usage) counts as the importing app's own session, or stays set aside + with the copy ([ADR-0052](0052-codex-usage-attribution.md), "Not done"). ## Verification (when implemented) From aae4f353ecf2a1a08a479e0d1993a9247c9bfe53 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:24:43 -0700 Subject: [PATCH 14/15] docs(audit): record Branch 1 and what it leaves open The audit still stated the 23-folder Desktop-origin problem as current and listed the discovery exclusion as follow-on work. It now has a Branch 1 entry with the re-measured 924 imports and 32 folders, and open items for the Full scan, the three views without the count and the six rollouts. --- ...-237-238-239-verification-and-decisions.md | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md b/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md index b4508017..d0ec655b 100644 --- a/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md +++ b/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md @@ -924,6 +924,19 @@ changed in this wave: - A worktree `.claude` modification time that changed during the review. It came from concurrent runs, and no user state changed. +#### Remediation program Branch 1 (`fix/imported-rollout-origins`, 2026-09-27) + +Added after the fact. This branch was built from `be1c1d47` for the +[remediation program](../superpowers/plans/2026-09-26-remediation-program.md), not on +`integration/237-238`, and it is not merged. It implements ADR-0060 §3 for project discovery: a Codex +rollout imported from a Claude Code transcript gives a folder no project, Codex host or Desktop +origin, and discovery counts it in `importedExcluded`, which the System KPI note shows. A read-only +count on this machine on 2026-09-27 found 924 imported rollouts (the 874 above was 2026-09-26) and +32 folders whose Desktop origin came only from imports (23 above). Commits: `0c56f48f` (discovery), +`a76738a4` (System note), `7e73a324` (ADR-0052), `bcdf9f89`, `c5f4e701`, then a review fix wave that +advances the footprint snapshot schema to v8 so a snapshot taken before the change is not shown +with the old origins. What remains is listed under Open items. + ### Full-suite results at each stage end #### Baseline on `847486c` @@ -1103,6 +1116,15 @@ Behavior that differs from, or goes beyond, the plan text. - To repair it: 1. Run **Re-measure machine** in the dashboard's Maintenance area. 2. Check that `locators.json` no longer mentions `/Users/someone`. +- **Run a Full scan after Branch 1 lands.** The footprint snapshot schema moves to v8, so System + and the Maintenance session-origin facet show the Projects section as not measured until **Full + scan** or `ak system --deep` writes a new snapshot. Before that, the 49 folders the v7 snapshot + lists with a Codex Desktop origin include the false ones. +- **Left open by Branch 1.** ADR-0060's status (it is still Proposed, and Branch 7 waits for its + acceptance). The Intelligence census line, the System → Projects liner and the `ak system` text + output show the smaller counts without the imported-copy count (ADR-0060 Implementation status). + 6 of the 924 imported rollouts hold a later turn that is not an import and has real token usage, + which whole-rollout exclusion drops; whether it counts is undecided (ADR-0052 "Not done"). - **The dashboard server's hermeticity guard has a gap.** It fires only when a maintenance service is injected without a control root. A caller that injects only a System collector still gets the default maintenance service and management facade, and both write real state. This product-side @@ -1466,7 +1488,8 @@ the raw value always kept, folders only as explanation, and one table of officia finding for current views: all 874 rollouts labelled `Codex Desktop` are Claude Code transcripts imported by the ChatGPT desktop app. Usage already excludes them (ADR-0052), but project discovery does not, so 23 project folders on this machine show a Desktop origin they never had. The work is -staged as follow-on, starting with that exclusion. +staged as follow-on, starting with that exclusion. (That exclusion is now implemented on Branch 1; see its entry under +Implementation status.) ### Plan changes @@ -1516,8 +1539,9 @@ the table: `24ddc42` (ships the constraint registry with ak, which the table did 6.6 and 6.7 are Branch 6a's items 1–2; 6.8–6.16 are Branch 6b's items 1–9 and 6.17 its item 11. The order constraints above still hold across those branches. -**Follow-on (not on this branch).** ADR-0060, beginning with removing imported copies from project -discovery and origin views, then the shared surface vocabulary; pruning of ak's settings safety +**Follow-on (not on this branch).** ADR-0060: removing imported copies from project discovery is +done on Branch 1; saying how many were set aside in the Intelligence census line, the System → +Projects liner and the `ak system` text output remains, then the shared surface vocabulary; pruning of ak's settings safety copies; the AQE audit-chain break; Cowork as a discovery source. ### Decision 10 — sync's exit code and hand-fix rows From 753ae69274cf80c7a615d11a87123747f1de095e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:39:49 -0700 Subject: [PATCH 15/15] docs(audit): record decisions 11 and 12 on imported copies --- docs/adr/0052-codex-usage-attribution.md | 5 +++-- ...ion-surface-initiator-and-product-names.md | 5 +++-- ...-237-238-239-verification-and-decisions.md | 20 ++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/adr/0052-codex-usage-attribution.md b/docs/adr/0052-codex-usage-attribution.md index 28e63445..fec79ad2 100644 --- a/docs/adr/0052-codex-usage-attribution.md +++ b/docs/adr/0052-codex-usage-attribution.md @@ -210,8 +210,9 @@ subagent and previously dropped usage is now priced. five of the six (2 to 76 per file) and non-zero `token_count` usage (the per-file sum of `last_token_usage.total_tokens` is about 8k to 449k). Both usage and discovery set the whole file aside at the marker, so this usage is not counted. With no `user_message`, the turn may be - automatic (a compaction or title pass). Measured from counts only; whether to count such turns is - undecided (see ADR-0060 open questions). + automatic (a compaction or title pass). Measured from counts only. Decided 2026-09-27 (audit + decision 12): Branch 8 excludes per turn instead of per file, so imported turns are never counted + and later turns are, after it establishes whether they are the user's work or an automatic pass. ## Verification diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index 77bb6c07..016fcfa3 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -178,8 +178,9 @@ in full. - Whether the "on 3P" attribute is worth showing. - How ADR-0057's role lenses consume surface and initiator. - Whether a later turn inside an imported copy that is not itself an import (6 of 924 rollouts on - 2026-09-27, with real token usage) counts as the importing app's own session, or stays set aside - with the copy ([ADR-0052](0052-codex-usage-attribution.md), "Not done"). + 2026-09-27, with real token usage) counts as the importing app's own session. Decided 2026-09-27 + (audit decision 12): it counts, excluded per turn in Branch 8 + ([ADR-0052](0052-codex-usage-attribution.md), "Not done"). ## Verification (when implemented) diff --git a/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md b/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md index d0ec655b..ecb38233 100644 --- a/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md +++ b/docs/audits/2026-09-26-issues-237-238-239-verification-and-decisions.md @@ -1124,7 +1124,8 @@ Behavior that differs from, or goes beyond, the plan text. acceptance). The Intelligence census line, the System → Projects liner and the `ak system` text output show the smaller counts without the imported-copy count (ADR-0060 Implementation status). 6 of the 924 imported rollouts hold a later turn that is not an import and has real token usage, - which whole-rollout exclusion drops; whether it counts is undecided (ADR-0052 "Not done"). + which whole-rollout exclusion drops. The maintainer decided on 2026-09-27 to count them in + Branch 8 (decision 12 below). - **The dashboard server's hermeticity guard has a gap.** It fires only when a maintenance service is injected without a control root. A caller that injects only a System collector still gets the default maintenance service and management facade, and both write real state. This product-side @@ -1587,3 +1588,20 @@ and [UPGRADING](../UPGRADING.md) updated to match. When a failing manual row rem reads "converged — nothing left that sync can repair" rather than "no failing subsystems". A manual row of a subsystem named by `--skip` is listed under "needs your action" rather than "skipped by request". + +### Decisions 11 and 12 — imported copies (Branch 1, 2026-09-27) + +Both questions came from Branch 1 and were presented in the decision format above. + +**Decision 11 — folders named only by imported copies.** On this machine 5 folders appear only in +Codex rollouts that the ChatGPT desktop app imported from Claude Code transcripts. With imports +excluded from discovery, they leave the "ever seen" project count (113 → 108). The choices were to +drop them, since an imported copy is not a session on this machine (ADR-0060 §3), or to credit the +sighting to Claude Code. **Recommendation: drop them. Choice: drop them.** + +**Decision 12 — real turns inside imported copies.** 6 of the 924 imported rollouts hold later turns +of real work in the ChatGPT desktop app, with token usage. Whole-rollout exclusion drops them from +usage (ADR-0052) and from discovery. The choices were: exclude per turn in Branch 8, which owns the +usage parsers; do it in Branch 1; or keep whole-rollout exclusion. **Recommendation: per turn, in +Branch 8. Choice: per turn, in Branch 8.** Imported turns are never counted, and later real turns +count as Codex usage in the ChatGPT desktop app and give their folder a genuine Desktop origin.