create-walle 0.10.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -41
- package/lib/runtime-closure-policy.json +2 -2
- package/package.json +10 -4
- package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
- package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
- package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
- package/template/coding-task-manager/lib/client-diag-handler.js +20 -0
- package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
- package/template/coding-task-manager/lib/codex-local-links.js +267 -0
- package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
- package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
- package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
- package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
- package/template/coding-task-manager/lib/feature-manifest.js +0 -2
- package/template/coding-task-manager/lib/harness-history-transition.js +38 -11
- package/template/coding-task-manager/lib/headless-term-service.js +5 -6
- package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
- package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
- package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
- package/template/coding-task-manager/lib/lead-model-switch.js +8 -0
- package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
- package/template/coding-task-manager/lib/login-tripwire.js +17 -0
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +4 -0
- package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
- package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
- package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
- package/template/coding-task-manager/lib/native-history-control.js +60 -12
- package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
- package/template/coding-task-manager/lib/raw-history-block.js +3 -1
- package/template/coding-task-manager/lib/read-pool-client.js +31 -22
- package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
- package/template/coding-task-manager/lib/remote-command-runtime.js +5 -4
- package/template/coding-task-manager/lib/root-prompt-admission.js +10 -9
- package/template/coding-task-manager/lib/root-prompt-flow.js +8 -5
- package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
- package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
- package/template/coding-task-manager/lib/session-create/provider-process.js +4 -2
- package/template/coding-task-manager/lib/session-lifecycle.js +2 -50
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +59 -3
- package/template/coding-task-manager/lib/session-resource-sampler.js +48 -0
- package/template/coding-task-manager/lib/session-status.js +2 -2
- package/template/coding-task-manager/lib/typing-interaction-trace.js +44 -2
- package/template/coding-task-manager/lib/typing-worker-stages.js +148 -1
- package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
- package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
- package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
- package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
- package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
- package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
- package/template/coding-task-manager/lib/ws/input.js +1 -0
- package/template/coding-task-manager/protocol/message-table.json +0 -10
- package/template/coding-task-manager/protocol/messages.js +0 -10
- package/template/coding-task-manager/protocol/ws-schema.json +0 -20
- package/template/coding-task-manager/protocol/ws-schema.md +0 -2
- package/template/coding-task-manager/public/css/app.css +0 -1
- package/template/coding-task-manager/public/index.html +3 -17
- package/template/coding-task-manager/public/js/client-health-core.js +81 -0
- package/template/coding-task-manager/public/js/client-health-main.js +61 -0
- package/template/coding-task-manager/public/js/prompt-submission-client.js +5 -1
- package/template/coding-task-manager/public/js/protocol.js +0 -10
- package/template/coding-task-manager/public/js/row-join.js +14 -1
- package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
- package/template/coding-task-manager/public/js/terminal-history-viewport.js +0 -2
- package/template/coding-task-manager/server.js +62 -186
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/wall-e/coding/host-capabilities.js +2 -2
- package/template/wall-e/fly.toml +1 -1
- package/template/wall-e/tools/local-tools.js +6 -1
- package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
package/README.md
CHANGED
|
@@ -1,44 +1,99 @@
|
|
|
1
1
|
# create-walle
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**One workspace for your coding agents. Context that carries forward.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install **CTM** (Coding Task Manager) and **Wall-E** in one command. CTM runs coding agents in real terminals on your machine and gives them a browser workspace for sessions, worktrees, approvals and review. Wall-E is the personal agent alongside it, keeping project knowledge and preferences for later work.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Different tasks suit different agents, so choose per session instead of committing to one up front.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Start at [walle.sh](https://walle.sh), or follow the [illustrated walkthrough](https://walle.sh/blog/paste-the-link/) for the same workflow with more detail and examples.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- **Delegated workers** — Delegate focused implementation tasks to Claude or Codex workers, and choose the model and effort for the work.
|
|
13
|
-
- **Prompts & queues** — Save prompts, organize them, and queue work for a deliberate sequence.
|
|
14
|
-
- **Review & approvals** — Review code and documents, with approval workflows where the connected agent supports them. Rich review is supported for Claude, Codex, OpenCode, and Wall-E.
|
|
15
|
-
- **Remote phone & tablet access** — Configure access to the running host from a phone or tablet when you need to check in away from your desk.
|
|
16
|
-
- **Models** — Set a default or choose a model by task and role. Claude and Codex support lead-model switching; Wall-E has its own native model configuration.
|
|
11
|
+
## Quick start
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
```bash
|
|
14
|
+
npx create-walle install ./walle
|
|
15
|
+
```
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
Install **Node.js 24.22.0 or later** and npm first. The installer manages the supported runtime for CTM and Wall-E; that runtime requires macOS 13.5 or later, or Linux with glibc 2.28 or later and kernel 4.18 or later.
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
- **Inbox and approvals** — One inbox for what needs your attention. Actions that leave your machine follow per-capability policies: allowed, asked for approval, or denied.
|
|
24
|
-
- **Jobs** — Scheduled and one-off work (briefings, reflections, Slack inbound) with run history. Nothing runs on a schedule unless you configure it.
|
|
25
|
-
- **Searchable memory** — Knowledge, entities, and timeline, available to the chat and to coding agents through MCP.
|
|
26
|
-
- **Connected sources** — Optional integrations (for example Slack, with auto-replies only to an allowlist you set). They require the relevant accounts and permissions.
|
|
27
|
-
- **Phone** — The same Wall-E views on the CTM phone app.
|
|
19
|
+
The command copies the project, installs dependencies and starts the server. Open **http://localhost:3456** to finish setup in the browser, then authenticate the agents and providers you choose and configure any optional connections or remote device access. The npm installation is the current path on macOS and Linux; the Mac app is still being updated.
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+

|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
Screenshots show the real application with fictional `acme-ledger` data and scripted agent dialogue.
|
|
32
24
|
|
|
33
|
-
|
|
25
|
+
## Why CTM and Wall-E
|
|
34
26
|
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
### Choose the agent for the task
|
|
28
|
+
|
|
29
|
+
Every session has its own agent choice. Run Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, Wall-E or a shell side by side and move between them from the same workspace.
|
|
30
|
+
|
|
31
|
+
The CLI, the model and the connection serving that model are separate decisions: the AI Providers area brings native agent options, direct provider connections and configured routers into one place, using your own accounts and credentials. Agents differ in what they support, so the available model-switching controls depend on the CLI you pick.
|
|
32
|
+
|
|
33
|
+
### See what each agent needs
|
|
34
|
+
|
|
35
|
+
Running, Needs You and Idle group the session list by what requires attention, and delegated workers appear beneath the lead session that started them.
|
|
36
|
+
|
|
37
|
+
The session summary answers the next question — what is this agent trying to finish? — by bringing together the current task, recent progress and how fresh that information is. "Add jitter to webhook retries. Implementation is ready; the duplicate-delivery test still needs review." After a break, that is enough to decide which session to open.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
### Coordinate worktrees, changes and workers
|
|
42
|
+
|
|
43
|
+
Start a session with its own **worktree** and CTM creates the branch and working directory and links them to the session, so an investigation and a test run can proceed in separate checkouts of the same repository.
|
|
44
|
+
|
|
45
|
+
The worktree panel shows **Ready to merge**, **Behind main** and **Dirty** states, uncommitted changes and branch drift, along with the sessions linked to each checkout. Changed-file and commit indicators connect a conversation to the code it touched, and describe the working tree rather than who wrote each line: sessions that share a checkout share that Git state.
|
|
46
|
+
|
|
47
|
+
For focused work, delegate to Claude or Codex workers, choose the model and effort, and inspect the result beneath the lead. Tasks that start servers still need separate ports and data directories. See the [delegation walkthrough](https://walle.sh/blog/delegation-guide/) for setup and an example task.
|
|
48
|
+
|
|
49
|
+
### Let routine permissions move forward
|
|
50
|
+
|
|
51
|
+
Repeated prompts interrupt work, so CTM applies rules for decisions you have already made, such as allowing a particular test command in the project you are working on.
|
|
52
|
+
|
|
53
|
+
For requests that need more judgment, an approval flow can check the request against the task with a scoped AI review. Requests held for you stay visible as Needs You, with the reason available to inspect, and the Permissions panel shows the policy behind each decision so you can change or disable a rule. These settings govern CTM's automatic approval; the selected CLI also keeps its own permission controls.
|
|
54
|
+
|
|
55
|
+
### Reuse the knowledge behind your code
|
|
56
|
+
|
|
57
|
+
Conversations hold more than the final patch: approaches that failed, constraints that mattered and the reasoning behind a decision. CTM records prompts, answers and tool calls as structured turns you can read and search by phrase, file reference or session link.
|
|
58
|
+
|
|
59
|
+
With Wall-E connected through MCP, paste the earlier session's URL into a new session and ask the agent to read that session, or let it search Wall-E:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Search Wall-E for the earlier retry investigation in acme-ledger.
|
|
63
|
+
Find the decision about the 30-second cap and use it when reviewing this diff.
|
|
37
64
|
```
|
|
38
65
|
|
|
39
|
-
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
The layers do different jobs. Recorded conversations preserve the discussion as it happened; search and selected context retrieval fit the parts that matter into a limited model context; durable memory keeps the facts, entities and preferences worth keeping. Retrieval is not automatic — the agent has to look, and it depends on which history and tools are connected — so check retrieved context against the current branch before relying on it.
|
|
69
|
+
|
|
70
|
+
### Keep work available without keeping everything running
|
|
71
|
+
|
|
72
|
+
CTM can park an eligible inactive session to stop its process and free resources while the recorded conversation stays in the workspace, then bring it back when you need it.
|
|
73
|
+
|
|
74
|
+
A shared MCP gateway lets eligible tool servers reuse one process across sessions; tools needing session-specific state keep separate instances.
|
|
75
|
+
|
|
76
|
+
### Review changes in one place
|
|
77
|
+
|
|
78
|
+
The Review page moves between rendered HTML or Markdown, source files and diffs, and new files appear before Git tracks them. Comments collect into one message for the session responsible for the change, keeping feedback beside the files that produced it.
|
|
79
|
+
|
|
80
|
+
For a small correction, open the current file in File or Source view, choose **Edit**, change the text and save: the working file changes and the diff reflects it, while historical revisions and binary files stay read-only.
|
|
81
|
+
|
|
82
|
+
### A personal agent with context about you
|
|
83
|
+
|
|
84
|
+
Wall-E keeps its own chat threads, an inbox for what needs your attention, and scheduled or one-off jobs with run history. Schedules run only when you configure them.
|
|
40
85
|
|
|
41
|
-
|
|
86
|
+
Saved preferences and project knowledge carry into later work. Store `Show the failure and test evidence before proposing a rollout`, for example, and a later review brief can start from it instead of restating the point.
|
|
87
|
+
|
|
88
|
+
Optional connected sources such as Slack need their own accounts and permissions, and Slack auto-replies stay inside the allowlist you configure.
|
|
89
|
+
|
|
90
|
+
## Try it on one project
|
|
91
|
+
|
|
92
|
+
This example is fictional and the dialogue is scripted; it illustrates the workflow rather than measuring an agent. A failed webhook delivery in `acme-ledger` needs better retry handling, so Claude Code investigates the retry behaviour in one session while Codex works on tests in a separate worktree.
|
|
93
|
+
|
|
94
|
+
A scoped test command proceeds under an approval rule, while a request to write outside the test transaction is held as Needs You until you release it.
|
|
95
|
+
|
|
96
|
+
Days later, a review session needs the reasoning behind the 30-second retry cap recorded during the earlier investigation. With Wall-E connected through MCP, paste that session's URL into the new session, or ask Wall-E to find it, and the agent works from the original reasoning while you read the design document and patch together.
|
|
42
97
|
|
|
43
98
|
## Commands
|
|
44
99
|
|
|
@@ -61,26 +116,15 @@ If an older installation asks for its existing Node executable during an update,
|
|
|
61
116
|
npx create-walle update --installed-node /absolute/path/to/node
|
|
62
117
|
```
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
On first launch, the browser setup page guides you through:
|
|
67
|
-
|
|
68
|
-
1. **Browser setup** — finish the local installation in the dashboard
|
|
69
|
-
2. **Providers** — authenticate the coding agents and model providers you use
|
|
70
|
-
3. **Integrations** — optionally connect available sources with their required permissions
|
|
71
|
-
4. **Remote phone and tablet access** — optionally configure access to the running host
|
|
72
|
-
|
|
73
|
-
## Custom Port
|
|
119
|
+
Run on another port with `CTM_PORT`. Wall-E runs on `CTM_PORT + 1` — 5001 in this example:
|
|
74
120
|
|
|
75
121
|
```bash
|
|
76
122
|
CTM_PORT=5000 npx create-walle install ./walle
|
|
77
123
|
```
|
|
78
124
|
|
|
79
|
-
Wall-E runs on `CTM_PORT + 1` (e.g., 5001).
|
|
80
|
-
|
|
81
125
|
## Hosting and storage
|
|
82
126
|
|
|
83
|
-
The dashboard and Wall-E memory run on the host where you install them
|
|
127
|
+
The dashboard and Wall-E memory run on the host where you install them, and SQLite is the default storage. Coding providers and optional integrations may contact their own services and require their own accounts or permissions. Use backups and exports to preserve or move local data; do not treat file-sync services as database replication.
|
|
84
128
|
|
|
85
129
|
| Component | Default Port | What it does |
|
|
86
130
|
|-----------|-------------|--------------|
|
|
@@ -89,13 +133,12 @@ The dashboard and Wall-E memory run on the host where you install them. SQLite i
|
|
|
89
133
|
|
|
90
134
|
## Updates and recovery
|
|
91
135
|
|
|
92
|
-
Run `npx create-walle update` from your installation directory. Updates verify compatibility, back up existing databases
|
|
93
|
-
|
|
94
|
-
Keep your backups available, and resolve an update error before retrying.
|
|
136
|
+
Run `npx create-walle update` from your installation directory. Updates verify compatibility, back up existing databases and check the services before reporting success, and report backup and recovery information if attention is needed. Keep your backups available, and resolve an update error before retrying.
|
|
95
137
|
|
|
96
138
|
## Links
|
|
97
139
|
|
|
98
140
|
- [Homepage](https://walle.sh)
|
|
141
|
+
- [Walkthrough](https://walle.sh/blog/paste-the-link/)
|
|
99
142
|
|
|
100
143
|
## License
|
|
101
144
|
|
|
@@ -1789,7 +1789,7 @@
|
|
|
1789
1789
|
"coding-task-manager/public/js/client-health-worker.js": "07e300113ee749ac7a6adaaa1461502d1562087af395b6c89cf1b885f1f5e00e",
|
|
1790
1790
|
"coding-task-manager/public/js/image-normalize-worker.js": "37cb0758bddcf10c66060fae52088c1a842409f30e919988e789cfea7d05e2be",
|
|
1791
1791
|
"coding-task-manager/public/m/app.js": "483f777f57efd28d7796713c33df48ffba47e5bfb50597b446a37b961f53d585",
|
|
1792
|
-
"coding-task-manager/server.js": "
|
|
1792
|
+
"coding-task-manager/server.js": "927a036ed5143c02b2c021e5b5c0a80def7f87d01524514108f90f438e48b776",
|
|
1793
1793
|
"scripts/conversation-storage-v2-migration.js": "80c66e41c9fb9b1d756fe21be17f3b07c6a65389712887043a86341fbf6a6879",
|
|
1794
1794
|
"shared/storage/schema-port.js": "e41cec8c075a6167fba2efd0177d9e20a11c0cd49ae176aaf36d5b9257409e67",
|
|
1795
1795
|
"shared/storage/schema-sync.js": "7ca080e44e35e8178a0f74317023881f920ae75bde4f7431702ce1f9fa4a1d03",
|
|
@@ -1815,7 +1815,7 @@
|
|
|
1815
1815
|
"wall-e/channels/telegram-channel.plugin.json": "696d601804c91387824e80263b784f76ce5cad4b8ae81277d4b4030c1710e437",
|
|
1816
1816
|
"wall-e/coding/action-memory-store.js": "5c7a467e9cb3e08dbfc7b27dec6724f8ea6eedac29d2c40f6c6bda3a4263a0c0",
|
|
1817
1817
|
"wall-e/coding/hooks/plugin-loader.js": "f1ca5e032e64f68447af9317ad75821a5152ca25fad09ce4ac23e8cd39ac891c",
|
|
1818
|
-
"wall-e/coding/host-capabilities.js": "
|
|
1818
|
+
"wall-e/coding/host-capabilities.js": "9dafbc2a76ba5e00911219316be7b5f57ca26db278275606ea7fad61631370bd",
|
|
1819
1819
|
"wall-e/coding/prompt-capabilities.js": "e230871618f9f43e6c7a3a7f98ebda2e77bb522018c6d4c0b1e29d0b08926708",
|
|
1820
1820
|
"wall-e/coding/user-hooks.js": "4a0d9b82894f6a23c126b09d8f1feca3aa894b171a9c815e11389b2d96b1e018",
|
|
1821
1821
|
"wall-e/lib/boot-preflight.js": "4415c7c3dae9add70bebdd29d75ee68d09097c53732438d35c3cd1ae0de5f01e",
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-walle",
|
|
3
|
-
"version": "0.10.
|
|
4
|
-
"description": "CTM + Wall-E
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "CTM + Wall-E: one workspace for coding agents, worktrees and review, with shared session context and a personal agent.",
|
|
5
|
+
"homepage": "https://walle.sh",
|
|
5
6
|
"bin": {
|
|
6
7
|
"create-walle": "bin/create-walle.js"
|
|
7
8
|
},
|
|
@@ -32,8 +33,13 @@
|
|
|
32
33
|
"task-manager",
|
|
33
34
|
"wall-e",
|
|
34
35
|
"ai",
|
|
35
|
-
"digital-twin",
|
|
36
36
|
"claude",
|
|
37
|
-
"personal-assistant"
|
|
37
|
+
"personal-assistant",
|
|
38
|
+
"coding-task-manager",
|
|
39
|
+
"coding-agents",
|
|
40
|
+
"codex",
|
|
41
|
+
"worktrees",
|
|
42
|
+
"mcp",
|
|
43
|
+
"memory"
|
|
38
44
|
]
|
|
39
45
|
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// D-9 (WP-0 of docs/codex-lead-routed-switch-history-handoff-2026-10-21.html): cut real records
|
|
5
|
+
// out of real Codex rollouts into sanitized fixtures, one file per Codex CLI version, so the
|
|
6
|
+
// scanner is exercised against what Codex actually serializes instead of a hand-written copy.
|
|
7
|
+
//
|
|
8
|
+
// node bin/capture-codex-rollout-records.js --out tests/fixtures/codex-rollout-records [--roots <dir,dir>]
|
|
9
|
+
//
|
|
10
|
+
// Default roots are ~/.codex/sessions and ~/.codex-local/sessions. For every distinct
|
|
11
|
+
// session_meta.cli_version found the tool writes <version>.jsonl holding one real example of the
|
|
12
|
+
// session_meta, thread_settings_applied (the one with the most optional keys), turn_context,
|
|
13
|
+
// task_started, task_complete, token_count, world_state (full:true) and, when the version produced
|
|
14
|
+
// one, a compacted record. Adding a new Codex version's fixture file is part of qualifying it.
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_ROOTS = [path.join(os.homedir(), '.codex', 'sessions'), path.join(os.homedir(), '.codex-local', 'sessions')];
|
|
21
|
+
// Kinds captured per version, written in this order. The optional kind is only present when the
|
|
22
|
+
// version wrote one; a missing optional record never fails the capture.
|
|
23
|
+
const REQUIRED_KINDS = Object.freeze(['thread_settings_applied', 'turn_context', 'task_started', 'task_complete', 'token_count', 'world_state']);
|
|
24
|
+
const OPTIONAL_KINDS = Object.freeze(['compacted']);
|
|
25
|
+
const MAX_FILES_PER_VERSION = 40;
|
|
26
|
+
const MAX_BYTES_PER_FILE = 24 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const FIXTURE_ID_PREFIX = 'a0000000-0000-4000-8000-';
|
|
30
|
+
// The one thread a fixture file describes. Every record is cut from a rollout of that thread, so
|
|
31
|
+
// the payload `thread_id`/`session_id` fields all name it (the scanner checks them against the
|
|
32
|
+
// session_meta lineage).
|
|
33
|
+
const FIXTURE_THREAD_ID = `${FIXTURE_ID_PREFIX}000000000001`;
|
|
34
|
+
const FIXTURE_PATH_ROOT = '/tmp/fixture';
|
|
35
|
+
const FIXTURE_TEXT = 'fixture text';
|
|
36
|
+
const FIXTURE_IMAGE_URL = 'data:image/png;base64,AAAA';
|
|
37
|
+
const FIXTURE_CIPHERTEXT = `gAAAA${'x'.repeat(40)}`;
|
|
38
|
+
// Every string under these keys is a filesystem location, whatever its depth in the record.
|
|
39
|
+
const PATH_KEYS = new Set(['cwd', 'path', 'paths', 'runtime_workspace_roots', 'workspace_roots',
|
|
40
|
+
'writable_roots', 'roots', 'dir', 'directory', 'home', 'jsonl_path', 'rollout_path']);
|
|
41
|
+
// Every string under these keys names the thread the fixture describes.
|
|
42
|
+
const THREAD_KEYS = new Set(['thread_id', 'session_id', 'threadid']);
|
|
43
|
+
// The replacement-item types `classifyReplacement` accepts in lib/history-transition-read-op.js.
|
|
44
|
+
// A compacted record outside this set is refused by the scanner, so it is not a usable fixture.
|
|
45
|
+
const REPLACEMENT_CALL_TYPES = new Set(['function_call', 'custom_tool_call', 'computer_call', 'local_shell_call']);
|
|
46
|
+
const REPLACEMENT_OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output',
|
|
47
|
+
'computer_call_output', 'local_shell_call_output']);
|
|
48
|
+
const REPLACEMENT_ITEM_TYPES = new Set(['text', 'summary', 'compaction', 'message']);
|
|
49
|
+
// Every string under these keys is prose the person wrote or Codex prompted with.
|
|
50
|
+
const TEXT_KEYS = new Set(['text', 'message', 'last_agent_message', 'developer_instructions',
|
|
51
|
+
'base_instructions', 'instructions', 'summary', 'prompt', 'description']);
|
|
52
|
+
// Prose and tool arguments arrive as one long string under keys this tool cannot enumerate
|
|
53
|
+
// (`arguments`, `plugins_instructions`, ...). Anything that long is text, not a protocol value.
|
|
54
|
+
const MAX_TEXT_LENGTH = 200;
|
|
55
|
+
// Default-deny: a short string survives only under a key whose values are protocol enums or
|
|
56
|
+
// identifiers of the protocol itself. Everything else -- approved command prefixes, network
|
|
57
|
+
// domains, MCP server and tool names, search queries, document and channel ids -- is local data
|
|
58
|
+
// and becomes FIXTURE_WORD (a token that also satisfies the scanner's identifier patterns).
|
|
59
|
+
const ENUM_KEYS = new Set(['type', 'role', 'model', 'model_provider_id', 'approval_policy',
|
|
60
|
+
'approvals_reviewer', 'mode', 'network', 'service_tier', 'reasoning_effort', 'reasoning_summary',
|
|
61
|
+
'effort', 'personality', 'status', 'phase', 'turn_trigger', 'sandbox', 'sandbox_mode',
|
|
62
|
+
'sandbox_policy', 'cli_version', 'originator', 'source', 'detail', 'timestamp', 'model_family',
|
|
63
|
+
'collaboration_mode', 'collaboration_mode_kind', 'kind', 'trigger', 'reason', 'stop_reason', 'end_turn', 'level']);
|
|
64
|
+
const ENUM_VALUE = /^[A-Za-z0-9@][A-Za-z0-9@._:+-]{0,63}$/;
|
|
65
|
+
const FIXTURE_WORD = 'fixture';
|
|
66
|
+
// The local account name, matched anywhere (including inside an ssh remote "<account>@host:path").
|
|
67
|
+
const ACCOUNT_NAME = new RegExp(os.userInfo().username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
68
|
+
const ENVELOPE_KEYS = Object.freeze(['timestamp', 'ordinal', 'type', 'payload']);
|
|
69
|
+
|
|
70
|
+
function parseArgs(argv) {
|
|
71
|
+
const args = { out: '', roots: [] };
|
|
72
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
73
|
+
const flag = argv[index];
|
|
74
|
+
if (flag === '--out') { args.out = String(argv[index + 1] || ''); index += 1; }
|
|
75
|
+
else if (flag === '--roots') {
|
|
76
|
+
args.roots = String(argv[index + 1] || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
77
|
+
index += 1;
|
|
78
|
+
} else if (flag === '--help' || flag === '-h') { args.help = true; }
|
|
79
|
+
else throw new Error(`unknown argument: ${flag}`);
|
|
80
|
+
}
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walkRollouts(root) {
|
|
85
|
+
const files = [];
|
|
86
|
+
const pending = [root];
|
|
87
|
+
while (pending.length) {
|
|
88
|
+
const dir = pending.pop();
|
|
89
|
+
let entries;
|
|
90
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
91
|
+
// expected: an absent or unreadable configured Codex home simply contributes no rollouts.
|
|
92
|
+
catch { continue; }
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const full = path.join(dir, entry.name);
|
|
95
|
+
if (entry.isDirectory()) pending.push(full);
|
|
96
|
+
else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) files.push(full);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readFirstLine(file, maxBytes = 256 * 1024) {
|
|
103
|
+
let fd;
|
|
104
|
+
try { fd = fs.openSync(file, 'r'); }
|
|
105
|
+
// expected: a rollout that vanished between listing and reading is skipped.
|
|
106
|
+
catch { return null; }
|
|
107
|
+
try {
|
|
108
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
109
|
+
const read = fs.readSync(fd, buffer, 0, maxBytes, 0);
|
|
110
|
+
const text = buffer.toString('utf8', 0, read);
|
|
111
|
+
const newline = text.indexOf('\n');
|
|
112
|
+
return newline < 0 ? text : text.slice(0, newline);
|
|
113
|
+
} catch { return null; }
|
|
114
|
+
finally { fs.closeSync(fd); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseRecord(line) {
|
|
118
|
+
if (!line.trim()) return null;
|
|
119
|
+
try { return JSON.parse(line); }
|
|
120
|
+
// expected: a rollout captured while a turn was running can end in a partial line.
|
|
121
|
+
catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function recordKind(record) {
|
|
125
|
+
if (!record || typeof record.type !== 'string') return '';
|
|
126
|
+
if (record.type === 'event_msg') return String(record.payload?.type || '');
|
|
127
|
+
return record.type;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function threadSettingsScore(record) {
|
|
131
|
+
const settings = record?.payload?.thread_settings;
|
|
132
|
+
return settings && typeof settings === 'object' ? Object.keys(settings).length : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Streams one rollout from the start, up to `maxBytes`, reporting the first (or best) example of
|
|
136
|
+
// every wanted kind and stopping as soon as all of them have been seen.
|
|
137
|
+
function collectKinds(file, { kinds, maxBytes = MAX_BYTES_PER_FILE, accept = null } = {}) {
|
|
138
|
+
const hits = new Map();
|
|
139
|
+
const scores = new Map();
|
|
140
|
+
const rejected = new Map();
|
|
141
|
+
let sessionMeta = null;
|
|
142
|
+
let fd;
|
|
143
|
+
try { fd = fs.openSync(file, 'r'); }
|
|
144
|
+
// expected: an unreadable rollout contributes nothing.
|
|
145
|
+
catch { return { hits, sessionMeta, rejected }; }
|
|
146
|
+
const buffer = Buffer.allocUnsafe(1 << 20);
|
|
147
|
+
let carry = '';
|
|
148
|
+
let bytes = 0;
|
|
149
|
+
try {
|
|
150
|
+
while (bytes < maxBytes) {
|
|
151
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
152
|
+
if (!read) break;
|
|
153
|
+
bytes += read;
|
|
154
|
+
carry += buffer.toString('utf8', 0, read);
|
|
155
|
+
const lines = carry.split('\n');
|
|
156
|
+
carry = lines.pop();
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const record = parseRecord(line);
|
|
159
|
+
if (!record) continue;
|
|
160
|
+
if (record.type === 'session_meta' && !sessionMeta) sessionMeta = record;
|
|
161
|
+
const kind = recordKind(record);
|
|
162
|
+
if (!kinds.has(kind)) continue;
|
|
163
|
+
const refusal = accept ? accept(kind, record) : '';
|
|
164
|
+
if (refusal) { if (!rejected.has(kind)) rejected.set(kind, refusal); continue; }
|
|
165
|
+
const score = kind === 'thread_settings_applied' ? threadSettingsScore(record) : 0;
|
|
166
|
+
if (!hits.has(kind) || score > (scores.get(kind) || 0)) { hits.set(kind, record); scores.set(kind, score); }
|
|
167
|
+
}
|
|
168
|
+
if ([...kinds].every((kind) => hits.has(kind))) break;
|
|
169
|
+
}
|
|
170
|
+
} catch { /* expected: a truncated read of an active rollout keeps what was parsed so far */ }
|
|
171
|
+
finally { fs.closeSync(fd); }
|
|
172
|
+
return { hits, sessionMeta, rejected };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pickVersionRecords(files) {
|
|
176
|
+
const candidates = files
|
|
177
|
+
.map((file) => ({ file, size: statSize(file) }))
|
|
178
|
+
.sort((a, b) => b.size - a.size || a.file.localeCompare(b.file))
|
|
179
|
+
.slice(0, MAX_FILES_PER_VERSION);
|
|
180
|
+
const wanted = new Set([...REQUIRED_KINDS, ...OPTIONAL_KINDS]);
|
|
181
|
+
const accept = (kind, record) => (kind === 'compacted' ? compactedRefusal(record) : '');
|
|
182
|
+
const picked = { sessionMeta: null, records: new Map(), skipped: new Map() };
|
|
183
|
+
for (const { file } of candidates) {
|
|
184
|
+
const remaining = new Set([...wanted].filter((kind) => !picked.records.has(kind)));
|
|
185
|
+
if (!remaining.size) break;
|
|
186
|
+
// The largest rollout for a version carries the most record types; after it, only the kinds
|
|
187
|
+
// still missing are searched for, so a version costs at most one full pass.
|
|
188
|
+
const found = collectKinds(file, { kinds: remaining, accept });
|
|
189
|
+
if (!picked.sessionMeta) picked.sessionMeta = found.sessionMeta;
|
|
190
|
+
for (const [kind, reason] of found.rejected) if (!picked.skipped.has(kind)) picked.skipped.set(kind, reason);
|
|
191
|
+
for (const [kind, record] of found.hits) {
|
|
192
|
+
const previous = picked.records.get(kind);
|
|
193
|
+
if (!previous) { picked.records.set(kind, record); continue; }
|
|
194
|
+
if (kind === 'thread_settings_applied' && threadSettingsScore(record) > threadSettingsScore(previous)) {
|
|
195
|
+
picked.records.set(kind, record);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return picked;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A compacted record is only a fixture when the scanner can classify its replacement window; the
|
|
203
|
+
// item types below mirror `classifyReplacement` (lib/history-transition-read-op.js). Codex writes
|
|
204
|
+
// `agent_message` items into some windows, which the scanner still refuses -- such a record is
|
|
205
|
+
// reported as skipped instead of being written into a fixture that cannot pass.
|
|
206
|
+
function compactedRefusal(record) {
|
|
207
|
+
const payload = record?.payload;
|
|
208
|
+
if (!payload || typeof payload !== 'object') return 'no payload';
|
|
209
|
+
const replacement = payload.replacement_history ?? payload.replacementHistory ?? payload.summary;
|
|
210
|
+
if (replacement === undefined) return '';
|
|
211
|
+
if (typeof replacement === 'string') return '';
|
|
212
|
+
if (!Array.isArray(replacement)) return 'replacement is not an array';
|
|
213
|
+
const calls = new Set();
|
|
214
|
+
const outputs = new Set();
|
|
215
|
+
for (const item of replacement) {
|
|
216
|
+
if (typeof item === 'string') continue;
|
|
217
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return 'replacement item is not an object';
|
|
218
|
+
const type = String(item.type || item.kind || '');
|
|
219
|
+
const id = String(item.call_id || item.id || '');
|
|
220
|
+
if (REPLACEMENT_ITEM_TYPES.has(type)) continue;
|
|
221
|
+
if (REPLACEMENT_CALL_TYPES.has(type)) {
|
|
222
|
+
if (!id || calls.has(id)) return 'replacement call id is missing or repeated';
|
|
223
|
+
calls.add(id);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (REPLACEMENT_OUTPUT_TYPES.has(type)) {
|
|
227
|
+
if (!id || !calls.has(id) || outputs.has(id)) return 'replacement output does not pair with a call';
|
|
228
|
+
outputs.add(id);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
return `replacement item type ${type || '(none)'}`;
|
|
232
|
+
}
|
|
233
|
+
return calls.size === outputs.size ? '' : 'replacement tool pair is unclosed';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function statSize(file) {
|
|
237
|
+
try { return fs.statSync(file).size; }
|
|
238
|
+
// expected: a rollout deleted mid-run is ranked last instead of aborting the capture.
|
|
239
|
+
catch { return 0; }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// `threadRealId` is the session_meta id of the fixture: it is the fixture's thread id, so every
|
|
243
|
+
// other record naming that thread keeps naming it.
|
|
244
|
+
function createSanitizer(threadRealId = '') {
|
|
245
|
+
const ids = new Map();
|
|
246
|
+
if (THREAD_ID.test(threadRealId)) ids.set(threadRealId, FIXTURE_THREAD_ID);
|
|
247
|
+
const paths = new Map();
|
|
248
|
+
let nextIdIndex = 2;
|
|
249
|
+
const idFor = (value) => {
|
|
250
|
+
if (!ids.has(value)) {
|
|
251
|
+
ids.set(value, `${FIXTURE_ID_PREFIX}${nextIdIndex.toString(16).padStart(12, '0')}`);
|
|
252
|
+
nextIdIndex += 1;
|
|
253
|
+
}
|
|
254
|
+
return ids.get(value);
|
|
255
|
+
};
|
|
256
|
+
const pathFor = (value) => {
|
|
257
|
+
if (!paths.has(value)) paths.set(value, `${FIXTURE_PATH_ROOT}/${paths.size + 1}`);
|
|
258
|
+
return paths.get(value);
|
|
259
|
+
};
|
|
260
|
+
return { idFor, pathFor, threadId: () => FIXTURE_THREAD_ID };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sanitizeValue(key, value, sanitizer, depth) {
|
|
264
|
+
if (typeof value === 'string') return sanitizeString(key, value, sanitizer);
|
|
265
|
+
if (depth > 64) return value;
|
|
266
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeValue(key, item, sanitizer, depth + 1));
|
|
267
|
+
if (value && typeof value === 'object') {
|
|
268
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitizeValue(childKey, child, sanitizer, depth + 1)]));
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sanitizeString(key, value, sanitizer) {
|
|
274
|
+
const name = key.toLowerCase();
|
|
275
|
+
if (name === 'encrypted_content') return FIXTURE_CIPHERTEXT;
|
|
276
|
+
if (name === 'image_url') return FIXTURE_IMAGE_URL;
|
|
277
|
+
if (PATH_KEYS.has(name)) return sanitizer.pathFor(value);
|
|
278
|
+
// Every record in the file belongs to the one fixture thread, exactly as in the rollout it was
|
|
279
|
+
// cut from, so the scanner's lineage check holds across records captured from different rollouts.
|
|
280
|
+
if (THREAD_KEYS.has(name)) return sanitizer.threadId();
|
|
281
|
+
if (THREAD_ID.test(value)) return sanitizer.idFor(value);
|
|
282
|
+
// A path or an account name can appear anywhere, including inside prose, tool arguments and an
|
|
283
|
+
// ssh remote ("<account>@host:path"). The substring test is deliberate: the account name is a
|
|
284
|
+
// substring of other local accounts too.
|
|
285
|
+
if (value.includes('/Users/') || value.includes('/home/') || ACCOUNT_NAME.test(value)) return FIXTURE_TEXT;
|
|
286
|
+
if (TEXT_KEYS.has(name) || value.length > MAX_TEXT_LENGTH) return FIXTURE_TEXT;
|
|
287
|
+
if (value.startsWith('/')) return sanitizer.pathFor(value);
|
|
288
|
+
if (ENUM_KEYS.has(name) && (ENUM_VALUE.test(value) || /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(value))) return value;
|
|
289
|
+
// An id the scanner pairs or compares (call_id, item id, ...) stays consistent within the file.
|
|
290
|
+
if (name === 'id' || name.endsWith('_id')) return sanitizer.idFor(value);
|
|
291
|
+
return FIXTURE_WORD;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sanitizeRecord(record, sanitizer) {
|
|
295
|
+
const envelope = {};
|
|
296
|
+
for (const key of ENVELOPE_KEYS) {
|
|
297
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
298
|
+
envelope[key] = key === 'payload' ? sanitizeValue(key, record.payload, sanitizer, 0) : record[key];
|
|
299
|
+
}
|
|
300
|
+
// Any envelope key Codex adds later is carried through, never dropped.
|
|
301
|
+
for (const [key, value] of Object.entries(record)) {
|
|
302
|
+
if (Object.hasOwn(envelope, key)) continue;
|
|
303
|
+
envelope[key] = sanitizeValue(key, value, sanitizer, 0);
|
|
304
|
+
}
|
|
305
|
+
return envelope;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fixtureRecords(picked) {
|
|
309
|
+
const sanitizer = createSanitizer(String(picked.sessionMeta?.payload?.id || ''));
|
|
310
|
+
const ordered = [];
|
|
311
|
+
if (picked.sessionMeta) ordered.push(picked.sessionMeta);
|
|
312
|
+
for (const kind of [...OPTIONAL_KINDS, ...REQUIRED_KINDS]) {
|
|
313
|
+
const record = picked.records.get(kind);
|
|
314
|
+
if (record) ordered.push(record);
|
|
315
|
+
}
|
|
316
|
+
return ordered.map((record, index) => sanitizeRecord(record, sanitizer));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function compareVersions(a, b) {
|
|
320
|
+
const parse = (value) => String(value).split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
321
|
+
const left = parse(a), right = parse(b);
|
|
322
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
323
|
+
if ((left[index] || 0) !== (right[index] || 0)) return (left[index] || 0) - (right[index] || 0);
|
|
324
|
+
}
|
|
325
|
+
return String(a).localeCompare(String(b));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function groupByVersion(files) {
|
|
329
|
+
const byVersion = new Map();
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
const first = parseRecord(readFirstLine(file) || '');
|
|
332
|
+
if (!first || first.type !== 'session_meta') continue;
|
|
333
|
+
const version = String(first.payload?.cli_version || first.payload?.version || '').trim();
|
|
334
|
+
if (!version) continue;
|
|
335
|
+
if (!byVersion.has(version)) byVersion.set(version, []);
|
|
336
|
+
byVersion.get(version).push(file);
|
|
337
|
+
}
|
|
338
|
+
return byVersion;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function capture({ outDir, roots }) {
|
|
342
|
+
const files = roots.flatMap((root) => walkRollouts(root));
|
|
343
|
+
const byVersion = groupByVersion(files);
|
|
344
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
345
|
+
const captured = [];
|
|
346
|
+
for (const version of [...byVersion.keys()].sort(compareVersions)) {
|
|
347
|
+
const picked = pickVersionRecords(byVersion.get(version));
|
|
348
|
+
const records = fixtureRecords(picked);
|
|
349
|
+
const missing = REQUIRED_KINDS.filter((kind) => !picked.records.has(kind));
|
|
350
|
+
fs.writeFileSync(path.join(outDir, `${version}.jsonl`), `${records.map((record) => JSON.stringify(record)).join('\n')}\n`);
|
|
351
|
+
captured.push({ version, records: records.length, files: byVersion.get(version).length,
|
|
352
|
+
compacted: picked.records.has('compacted'), missing, skipped: picked.skipped });
|
|
353
|
+
}
|
|
354
|
+
return captured;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const README = `# Codex rollout record fixtures
|
|
358
|
+
|
|
359
|
+
One file per Codex CLI version (\`<cli_version>.jsonl\`), holding real rollout records cut from real
|
|
360
|
+
sessions and sanitized. These are what the scanner in \`lib/history-transition-read-op.js\` is
|
|
361
|
+
validated against, so schema drift shows up as a test failure instead of a refused provider switch.
|
|
362
|
+
|
|
363
|
+
Each file holds, in order: the session_meta that names the fixture thread, and then one record of
|
|
364
|
+
each kind Codex writes — \`compacted\` when the version produced one, \`thread_settings_applied\`
|
|
365
|
+
(the record with the most optional keys), \`turn_context\`, \`task_started\`, \`task_complete\`,
|
|
366
|
+
\`token_count\`, \`world_state\` (\`full: true\`).
|
|
367
|
+
|
|
368
|
+
Sanitization (bin/capture-codex-rollout-records.js): every string under a path key
|
|
369
|
+
(\`cwd\`, \`path\`, \`runtime_workspace_roots\`, \`workspace_roots\`, \`writable_roots\`, ...) and every
|
|
370
|
+
other absolute path becomes \`/tmp/fixture/<n>\`; every id-like uuid (thread, turn, response,
|
|
371
|
+
window) becomes a fixture uuid that is consistent within a file; prose keys (\`text\`, \`message\`,
|
|
372
|
+
\`last_agent_message\`, \`developer_instructions\`, \`base_instructions\`, ...) and any string that
|
|
373
|
+
carries a home path or the account name become \`fixture text\`; \`encrypted_content\` becomes
|
|
374
|
+
\`gAAAA\` + 40 x's; \`image_url\` becomes a tiny data uri. Every other short string is
|
|
375
|
+
default-deny: it survives only under a protocol-enum key (\`ENUM_KEYS\`: type, role, model, status,
|
|
376
|
+
approval_policy, ...); an \`id\`/\`*_id\` value becomes a consistent fixture uuid and anything
|
|
377
|
+
else -- approved command prefixes, network domains, MCP names, queries -- becomes \`fixture\`.
|
|
378
|
+
Key sets and value types are unchanged.
|
|
379
|
+
|
|
380
|
+
## Regenerate
|
|
381
|
+
|
|
382
|
+
\`\`\`
|
|
383
|
+
cd coding-task-manager
|
|
384
|
+
node bin/capture-codex-rollout-records.js --out tests/fixtures/codex-rollout-records
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
387
|
+
Add \`--roots <dir,dir>\` to read other Codex homes (default \`~/.codex/sessions\`,
|
|
388
|
+
\`~/.codex-local/sessions\`). After a Codex upgrade, capture the new version's file and review the
|
|
389
|
+
diff: **adding a new Codex version's fixture file is part of qualifying that version** (see
|
|
390
|
+
\`docs/codex-harness-version-qualification-2026-10-19.html\`).
|
|
391
|
+
|
|
392
|
+
Verify no real path or account name survives: \`tests/codex-rollout-record-fixtures.test.js\` greps
|
|
393
|
+
every fixture for the home-directory prefix and for the account name, and fails if either appears.
|
|
394
|
+
`;
|
|
395
|
+
|
|
396
|
+
function main(argv) {
|
|
397
|
+
const args = parseArgs(argv);
|
|
398
|
+
if (args.help || !args.out) {
|
|
399
|
+
process.stdout.write('usage: node bin/capture-codex-rollout-records.js --out <dir> [--roots <dir,dir>]\n');
|
|
400
|
+
return args.help ? 0 : 2;
|
|
401
|
+
}
|
|
402
|
+
const roots = (args.roots.length ? args.roots : DEFAULT_ROOTS).map((root) => path.resolve(root));
|
|
403
|
+
const outDir = path.resolve(process.cwd(), args.out);
|
|
404
|
+
const captured = capture({ outDir, roots });
|
|
405
|
+
fs.writeFileSync(path.join(outDir, 'README.md'), README);
|
|
406
|
+
for (const entry of captured) {
|
|
407
|
+
const compactedNote = entry.compacted ? 'compacted'
|
|
408
|
+
: `no-compacted${entry.skipped.has('compacted') ? ` (${entry.skipped.get('compacted')})` : ''}`;
|
|
409
|
+
const notes = [entry.missing.length ? `missing ${entry.missing.join(',')}` : 'complete', compactedNote];
|
|
410
|
+
process.stdout.write(`${entry.version}: ${entry.records} records from ${entry.files} rollout(s) (${notes.join(', ')})\n`);
|
|
411
|
+
}
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
416
|
+
|
|
417
|
+
module.exports = { capture, sanitizeValue, createSanitizer, walkRollouts, groupByVersion, fixtureRecords };
|