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/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/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/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, diff --git a/docs/adr/0052-codex-usage-attribution.md b/docs/adr/0052-codex-usage-attribution.md index 3e5f58c8..fec79ad2 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 @@ -196,6 +204,15 @@ 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. 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 ba844a9c..016fcfa3 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; §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 + (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), @@ -160,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; @@ -173,6 +177,10 @@ 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. 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) @@ -182,4 +190,17 @@ 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). + +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`). 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/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..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 @@ -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,16 @@ 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. 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 @@ -1466,7 +1489,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 +1540,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 @@ -1563,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. diff --git a/docs/ddd/machine-footprint.md b/docs/ddd/machine-footprint.md index cb3eb1a3..4bd97da5 100644 --- a/docs/ddd/machine-footprint.md +++ b/docs/ddd/machine-footprint.md @@ -644,11 +644,15 @@ 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; 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)).** `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 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. 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 | |------|---------| 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..ac7e7eb3 --- /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, 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. | +| 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`. 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/dashboard/client/system-readout.mjs b/src/lib/dashboard/client/system-readout.mjs index 1d8d7ccb..3afaf472 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; 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/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/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/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/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); }); diff --git a/tests/kit/project-sources-imports.test.mjs b/tests/kit/project-sources-imports.test.mjs new file mode 100644 index 00000000..d0353212 --- /dev/null +++ b/tests/kit/project-sources-imports.test.mjs @@ -0,0 +1,157 @@ +// 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'); +}); + +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' }); +}); diff --git a/tests/kit/system-summary.test.mjs b/tests/kit/system-summary.test.mjs index 393e0a34..5b76ea72 100644 --- a/tests/kit/system-summary.test.mjs +++ b/tests/kit/system-summary.test.mjs @@ -232,6 +232,28 @@ 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(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'); + } +}); + // ── The page reads the slim endpoint ──────────────────────────────────────── test('loadSystem fetches /api/system/summary, deep refresh parameters included', async () => {