@henols/c64-re-tools 2.0.0-rc.11 → 2.0.0-rc.12

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.
Files changed (52) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +9 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +4 -4
  5. package/skills/c64-basic/SKILL.md +8 -1
  6. package/skills/c64-basic/scripts/basic.ts +3 -3
  7. package/skills/c64-disk/SKILL.md +10 -3
  8. package/skills/c64-disk/scripts/disk.ts +4 -4
  9. package/skills/c64-emulator/SKILL.md +4 -0
  10. package/skills/c64-knowledge/SKILL.md +21 -9
  11. package/skills/c64-knowledge/scripts/knowledge.ts +129 -90
  12. package/skills/c64-memory-map/SKILL.md +7 -0
  13. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  14. package/skills/c64-provenance/SKILL.md +56 -3
  15. package/skills/c64-provenance/scripts/library.ts +584 -0
  16. package/skills/c64-provenance/scripts/provenance.ts +201 -9
  17. package/skills/c64-reverse-engineering/SKILL.md +15 -2
  18. package/skills/c64-source/SKILL.md +10 -3
  19. package/skills/c64-source/scripts/generate.ts +10 -6
  20. package/skills/c64-source/scripts/opcodes.ts +19 -0
  21. package/skills/c64-source/scripts/source.ts +87 -13
  22. package/skills/c64-static-analysis/SKILL.md +9 -2
  23. package/skills/c64-static-analysis/scripts/analyze.ts +6 -6
  24. package/skills/c64-testing/SKILL.md +8 -1
  25. package/skills/c64-testing/references/scenario-format.md +1 -1
  26. package/skills/c64-testing/scripts/checklist.ts +2 -2
  27. package/skills/c64-testing/scripts/run.ts +4 -4
  28. package/skills/c64-testing/scripts/scenario.ts +1 -1
  29. package/skills/c64-testing/scripts/test.ts +4 -4
  30. package/skills/c64-unpacker/SKILL.md +11 -4
  31. package/skills/c64-unpacker/scripts/unpack.ts +4 -4
  32. package/src/cli/main.ts +5 -3
  33. package/src/host-client/image.ts +1 -1
  34. package/src/host-client/transfer.ts +34 -10
  35. package/src/host-client/vice-session.ts +9 -9
  36. package/src/knowledge/conflicts.ts +145 -0
  37. package/src/knowledge/database.ts +150 -38
  38. package/src/knowledge/history.ts +25 -20
  39. package/src/knowledge/import.ts +3 -2
  40. package/src/knowledge/read.ts +41 -19
  41. package/src/knowledge/schema.ts +68 -71
  42. package/src/knowledge/text.ts +364 -0
  43. package/src/knowledge/write.ts +140 -45
  44. package/src/mcp/output.ts +14 -7
  45. package/src/mcp/tools/debug.ts +8 -6
  46. package/src/mcp/tools/media.ts +17 -13
  47. package/src/mcp/tools/memory.ts +9 -5
  48. package/src/mcp/tools/video.ts +6 -5
  49. package/src/platform.ts +28 -5
  50. package/src/project.ts +166 -3
  51. package/src/protocol/tools.ts +1 -1
  52. package/src/script.ts +46 -7
package/README.md CHANGED
@@ -12,8 +12,8 @@ functional-equivalence tests.
12
12
  Architecture in one line: one MCP process owns one VICE instance and is
13
13
  VICE-only; a Host Runtime on the graphical host owns VICE and the tools that
14
14
  come with it (c1541, petcat); skill scripts call the Host Runtime for those,
15
- run ACME, DXA and Ghidra themselves, and keep project knowledge local in
16
- `.c64-re-tools/knowledge.db`.
15
+ run ACME, DXA and Ghidra themselves, and keep project knowledge local as
16
+ text in `.c64-re-tools/knowledge/`.
17
17
  The design is in [`docs/redesign/`](docs/redesign/README.md), and
18
18
  [`docs/plan.md`](docs/plan.md) tracks the implementation.
19
19
 
@@ -37,6 +37,8 @@ npx -y @henols/c64-re-tools@latest install --target claude
37
37
  claude
38
38
  ```
39
39
 
40
+ Several programs in one repository: install once at the root, give each program its own folder, and start the agent at the root. A folder with `.c64-re-tools/` is a project; the agent names one with `--project <folder>`.
41
+
40
42
  `npx -y @henols/c64-re-tools@latest status` shows what works.
41
43
 
42
44
  Until 2.0.0 is released, `@latest` is the old 1.1.0. Use the prerelease instead. For the prerelease, a dev container and debugging, see [docs/debug-install.md](docs/debug-install.md).
@@ -76,7 +78,7 @@ and settings stay unchanged.
76
78
 
77
79
  1. Install for your harness: `--target claude`, `codex`, `pi`, `opencode`, `gemini`, `copilot`, `cursor` or `windsurf`. Without `--target`, `install` installs for all of them.
78
80
  2. Start the Host Runtime and leave it running.
79
- 3. Start the harness in the project, for example `claude` or `codex`, or open the project in Cursor or Windsurf. The harness starts the MCP server and finds the skills by itself.
81
+ 3. Start the harness in the project (or at the root of a repository with several projects), for example `claude` or `codex`, or open the project in Cursor or Windsurf. The harness starts the MCP server and finds the skills by itself.
80
82
  4. Ask for the result you want, in plain words. The agent picks the skills and the `c64_*` tools.
81
83
 
82
84
  Examples:
@@ -93,7 +95,7 @@ Build it, run it in the emulator, and write a test for it.
93
95
  Show me the emulator window.
94
96
  ```
95
97
 
96
- The agent keeps what it learns about the program in `.c64-re-tools/knowledge.db`. Commit that file with the project.
98
+ The agent keeps what it learns about the program as text in `.c64-re-tools/knowledge/`. Commit that folder with the project. After a `git pull` that joined the work of two computers, run `knowledge.ts merge` when a command reports conflicts.
97
99
 
98
100
  ## Skills
99
101
 
@@ -107,7 +109,7 @@ The agent keeps what it learns about the program in `.c64-re-tools/knowledge.db`
107
109
  | c64-basic | BASIC V2 listings and the SYS/USR handoff to machine code. |
108
110
  | c64-unpacker | Packing evidence, and capture of the unpacked program from the emulator. |
109
111
  | c64-memory-map | Platform meaning of addresses and register values. |
110
- | c64-provenance | Byte comparison of releases. |
112
+ | c64-provenance | Byte comparison of releases, and a library of routines that several programs share. |
111
113
  | c64-source | ACME source from the image and the knowledge, checked byte by byte, and a loader for the entry state. |
112
114
  | c64-assembler | ACME builds with diagnostics and symbols. |
113
115
  | c64-testing | Original-against-rebuild scenarios with PASS, FAIL or INCONCLUSIVE. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "2.0.0-rc.11",
3
+ "version": "2.0.0-rc.12",
4
4
  "description": "Agent-friendly Commodore 64 engineering toolkit for reverse engineering, debugging, development and testing.",
5
5
  "license": "MIT",
6
6
  "author": "Henrik Olsson",
@@ -13,9 +13,9 @@ This skill does not run the program and does not change project knowledge.
13
13
 
14
14
  ## Inputs
15
15
 
16
- - The source root: the project directory that holds all source files of the program.
16
+ - The source root: the folder in the project that holds all source files of the program.
17
17
  - The entry source: the main file, relative to the source root.
18
- - The output path: where to write the PRG, relative to the project directory.
18
+ - The output path: where to write the PRG, relative to the project folder.
19
19
  - Optional include directories, symbol definitions and a start address.
20
20
 
21
21
  ## Tools / execution path
@@ -37,6 +37,13 @@ The script copies the full source root. Put all files that the program includes
37
37
 
38
38
  The script prints one JSON object. The target CPU is the 6510 and the output is always a C64 PRG.
39
39
 
40
+ ### Project folder
41
+
42
+ - Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
43
+ - If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
44
+ - Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
45
+ - A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
46
+
40
47
  ## Workflow
41
48
 
42
49
  1. Find the source root and the entry source. The entry source sets its own start address with `* = $0801` or similar.
@@ -16,7 +16,7 @@ import { compareBuild } from "./compare.ts";
16
16
 
17
17
  const USAGE = `assemble.ts --source-root <dir> --entry <file> --out <file.prg> [options]
18
18
 
19
- --source-root <dir> project directory to assemble from; all of it is sent
19
+ --source-root <dir> folder in the project to assemble from; all of it is sent
20
20
  --entry <file> the main source file, relative to the source root
21
21
  --out <file.prg> where to write the program, relative to the project
22
22
  --include <dir> include directory relative to the source root (repeatable)
@@ -61,7 +61,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
61
61
  try {
62
62
  outPath = resolveProjectPath(out);
63
63
  } catch {
64
- throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${out}`);
64
+ throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${out}`);
65
65
  }
66
66
  const defines = Object.fromEntries((values.define ?? []).map(parseDefine));
67
67
  let setPc: number | undefined;
@@ -101,8 +101,8 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
101
101
  }
102
102
 
103
103
  await runScript(
104
- async () => {
105
- const { output, failed } = await run(process.argv.slice(2));
104
+ async (argv) => {
105
+ const { output, failed } = await run(argv);
106
106
  if (failed) process.exitCode = 1;
107
107
  return output;
108
108
  },
@@ -13,7 +13,7 @@ This skill does not run the program and does not change project knowledge.
13
13
 
14
14
  ## Inputs
15
15
 
16
- - The program: a PRG file, relative to the project directory.
16
+ - The program: a PRG file, relative to the project folder.
17
17
 
18
18
  ## Tools / execution path
19
19
 
@@ -25,6 +25,13 @@ node <skill>/scripts/basic.ts extracted/game.prg
25
25
 
26
26
  The script prints one JSON object. Addresses are `$` and four hex digits.
27
27
 
28
+ ### Project folder
29
+
30
+ - Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
31
+ - If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
32
+ - Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
33
+ - A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
34
+
28
35
  ## Workflow
29
36
 
30
37
  1. Run the script on the program.
@@ -11,7 +11,7 @@ import { runScript, UsageError } from "#src/script.ts";
11
11
 
12
12
  const USAGE = `basic.ts <program>
13
13
 
14
- <program> is a PRG file relative to the project directory. The result is one
14
+ <program> is a PRG file relative to the project folder. The result is one
15
15
  JSON object. Addresses are $ and four hex digits.`;
16
16
 
17
17
  async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }> {
@@ -37,8 +37,8 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
37
37
  }
38
38
 
39
39
  await runScript(
40
- async () => {
41
- const { output, failed } = await run(process.argv.slice(2));
40
+ async (argv) => {
41
+ const { output, failed } = await run(argv);
42
42
  if (failed) process.exitCode = 1;
43
43
  return output;
44
44
  },
@@ -13,10 +13,10 @@ This skill does not change the disk image and does not change project knowledge.
13
13
 
14
14
  ## Inputs
15
15
 
16
- - The disk image: a `.d64`, `.d71`, `.d81` or `.g64` file, relative to the project directory.
16
+ - The disk image: a `.d64`, `.d71`, `.d81` or `.g64` file, relative to the project folder.
17
17
  - For one file: the file name as the directory shows it.
18
- - To extract a file: the output path, relative to the project directory.
19
- - A T64 tape file or a CRT cartridge file, relative to the project directory.
18
+ - To extract a file: the output path, relative to the project folder.
19
+ - A T64 tape file or a CRT cartridge file, relative to the project folder.
20
20
 
21
21
  ## Tools / execution path
22
22
 
@@ -42,6 +42,13 @@ The script prints one JSON object.
42
42
 
43
43
  File names show the characters that the C64 shows in a directory list. A byte that has no character shows as `{$xx}`, for example `{$c1}`. Write the name in the same form. The letter case of a name is not important. Put the name in single quotes in a shell, for example `'GAME {$c1}'`.
44
44
 
45
+ ### Project folder
46
+
47
+ - Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
48
+ - If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
49
+ - Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
50
+ - A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
51
+
45
52
  ## Workflow
46
53
 
47
54
  1. Run `directory`. Find the file in `entries`. Each entry has a `name`, a `type`, a size in `blocks` and the `closed` and `locked` flags.
@@ -23,7 +23,7 @@ const USAGE = `disk.ts <action> <image> [name] [--out <file>]
23
23
  read <image> <name> --out <file> write the bytes of one file into the project
24
24
  crt <file.crt> --out <dir> write each CHIP packet of a cartridge as a PRG
25
25
 
26
- <image> is a .d64, .d71, .d81 or .g64 file, relative to the project directory.
26
+ <image> is a .d64, .d71, .d81 or .g64 file, relative to the project folder.
27
27
  directory and read also take a .t64 tape file, and directory a .crt cartridge
28
28
  file; the script reads those itself. A CRT can have hardware type 0 (normal
29
29
  8K, 16K or Ultimax), 5 (Ocean), 19 (Magic Desk) or 32 (EasyFlash).
@@ -49,7 +49,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
49
49
  try {
50
50
  outPath = resolveProjectPath(values.out);
51
51
  } catch {
52
- throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
52
+ throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
53
53
  }
54
54
  }
55
55
 
@@ -111,8 +111,8 @@ function local(kind: "t64" | "crt", action: Action, image: string, name: string
111
111
  }
112
112
 
113
113
  await runScript(
114
- async () => {
115
- const { output, failed } = await run(process.argv.slice(2));
114
+ async (argv) => {
115
+ const { output, failed } = await run(argv);
116
116
  if (failed) process.exitCode = 1;
117
117
  return output;
118
118
  },
@@ -52,6 +52,10 @@ For a large result, give `out` with a path in the project. `c64_memory_read`, `c
52
52
 
53
53
  `c64_memmap` keeps RAM, ROM and I/O apart. Use action `summary` to see which code ran. Use action `read` with `out` to keep a coverage run as a file, for example `coverage/attract.bin`. Clear the map before each run that you want to keep apart.
54
54
 
55
+ ### Project folder
56
+
57
+ Paths (`path`, `file` and `out`) are relative to the project folder. A project folder holds `.c64-re-tools/`. The tools use the nearest project folder at or above the folder where the agent started. If that folder is not in a project and has projects below it, a tool with a path refuses and lists them. Then give `project` with the project folder, for example `"project": "game"`. Give the same folder as the `--project` of the skill scripts. A tool without a path does not need `project`.
58
+
55
59
  ## Workflow
56
60
 
57
61
  1. Read the project knowledge for the addresses in the question. Use the c64-knowledge skill.
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: c64-knowledge
3
- description: Use this skill to read, write, correct and review the durable knowledge of a C64 project in .c64-re-tools/knowledge.db. It gives the knowledge at an address, and it names symbols, classifies memory regions, sets comments and records references. It also shows the history of each change, and it can revert to an earlier revision. Do not use it for live emulator state or for static analysis.
3
+ description: Use this skill to read, write, correct and review the durable knowledge of a C64 project in .c64-re-tools/knowledge/. It gives the knowledge at an address, and it names symbols, classifies memory regions, sets comments and records references. It also shows the history of each change, and it can revert to an earlier revision. Do not use it for live emulator state or for static analysis.
4
4
  ---
5
5
 
6
6
  # c64-knowledge
7
7
 
8
8
  ## Purpose
9
9
 
10
- Read, write, correct and review the durable understanding of a C64 project. The knowledge is in `.c64-re-tools/knowledge.db` in the project directory. Git can version that file.
10
+ Read, write, correct and review the durable understanding of a C64 project. The knowledge is text in `.c64-re-tools/knowledge/` in the project folder: one JSON line for each row. Commit that folder with git. The script keeps an index of the text in `.c64-re-tools/local/knowledge.db` and makes the index again when the text changes. Do not commit the index.
11
11
 
12
12
  This skill is the only write path for conclusions of the user and the LLM. It does not run an emulator or an analyzer.
13
13
 
@@ -22,8 +22,6 @@ One of these:
22
22
 
23
23
  ## Tools / execution path
24
24
 
25
- Run the script in the project directory. The project directory is the directory that the harness started in.
26
-
27
25
  ```
28
26
  node <skill>/scripts/knowledge.ts <command> [arguments] [options]
29
27
  ```
@@ -41,7 +39,7 @@ The script prints one JSON object. An error prints `{"error": {"code": ..., "mes
41
39
  | Remove a symbol | `remove-symbol '$2100'` |
42
40
  | Classify memory | `classify '$3000' '$303f' sprite` |
43
41
  | Classify a table of records | `classify '$3000' '$303b' bytes --element 6` |
44
- | Many writes as one revision | `apply batch.json`, or `apply -` with the JSON on standard input |
42
+ | Many writes as one revision | `apply -` with the JSON on standard input, or `apply <file>` |
45
43
  | Make memory unknown, and keep the analyzers out of it | `unclassify '$3000' '$303f'` |
46
44
  | Set or remove a comment | `comment '$2100' line Reads joystick 2.` / `uncomment '$2100' line` |
47
45
  | Add or remove a reference | `reference '$2000' '$2100' call` / `unreference ...` |
@@ -51,8 +49,11 @@ The script prints one JSON object. An error prints `{"error": {"code": ..., "mes
51
49
  | Operand expressions | `operands` with `--start` and `--end` |
52
50
  | Files of generated source | `modules` |
53
51
  | The address spaces that hold knowledge | `spaces` |
54
- | History | `history '$2100'`, `history --name update_player`, `revisions`, `revision 12` |
55
- | Go back to an earlier state | `revert 12` |
52
+ | History | `history '$2100'`, `history --name update_player`, `revisions`, `revision 20261015T2145` |
53
+ | Go back to an earlier state | `revert 20261015T2145` |
54
+ | Decide the conflicts after a git merge | `merge --reason 'merge of two computers'` |
55
+
56
+ A revision has an id such as `20261015T214501.123-k3f9qz`. The id starts with the date and time. A command takes the full id, or a start of the id that only one revision has. The revision `0` is the knowledge before the first revision.
56
57
 
57
58
  Every command takes `--space`: `c64` (the default), `drive8` for code in the 1541 drive, or `bank0` to `bank255` for cartridge banks. Drive code and banks use the same addresses as the C64, so each space has its own knowledge. A name is unique inside one space. In a batch, a write can name its own `space`.
58
59
 
@@ -73,6 +74,13 @@ Region types:
73
74
 
74
75
  `--element` gives the bytes of one element of a table. The length of the region must be a multiple of it. The result shows `element` and `count`.
75
76
 
77
+ ### Project folder
78
+
79
+ - Paths are relative to the project folder. The project folder is the nearest folder at or above the working directory that holds `.c64-re-tools/`.
80
+ - If the working directory is not in a project and has projects below it, the script refuses and lists them. Then give `--project <folder>` to each script.
81
+ - Keep the results, batches and step files that you save for yourself in `.c64-re-tools/local/` of the project. Git ignores that folder.
82
+ - A shell redirect (`>`) is relative to the working directory, not to the project folder. From the folder above the project, write `<folder>/.c64-re-tools/local/result.json`.
83
+
76
84
  ## Workflow
77
85
 
78
86
  Read:
@@ -89,7 +97,7 @@ Write:
89
97
 
90
98
  Each write is one revision. A classification inside a region splits that region, so regions never overlap.
91
99
 
92
- For more than a few writes, put them in one JSON file and use `apply`. The file is a list of writes. Each write has `op` and the arguments of the command with the same name, for example:
100
+ For more than a few writes, use `apply -` and give the writes on standard input as one JSON list. Each write has `op` and the arguments of the command with the same name, for example:
93
101
 
94
102
  ```json
95
103
  [
@@ -117,6 +125,8 @@ The ops and their fields:
117
125
 
118
126
  A `line` comment is a line of its own above the address. A `side` comment is at the end of the line of the address. All writes become one revision. If the knowledge refuses one write, the script writes nothing, and the error names the write.
119
127
 
128
+ The c64-provenance skill writes a batch with `match --out`. The batch holds the names of library routines at their addresses in this program. Read the `batch.leftOut` of its result first. Then apply the batch with the command in its `apply`.
129
+
120
130
  ## Modules
121
131
 
122
132
  A module tells the c64-source skill where a file of the generated source starts. The file runs to the next module or to the end of its segment. Each address has one module, and each file name has one module. A file name has letters, digits, underscores or hyphens, and ends in `.asm`. The c64-source skill writes `main.asm`, `loader.asm` and `constants.asm` itself. Thus a module cannot have one of these names, in upper or lower case. The title becomes the first line of the file. A `module` write without a title keeps the title that the module has. Modules have a history, and `revert` restores them.
@@ -181,7 +191,8 @@ The exit status is 0 for a result. The exit status is 1 for an error from the kn
181
191
  - `conflict`: the change contradicts current knowledge. For example, the name belongs to another address. Read that address and its history, then decide.
182
192
  - `stale-revision`: the knowledge changed after you read it. Read it again, then decide again.
183
193
  - `not-found`: there is nothing to remove or no such revision.
184
- - `invalid-database` or `unsupported-schema`: tell the user the message. Do not edit the file.
194
+ - `conflict` with "from a git merge": git joined the knowledge of two computers, and two rows are current where the knowledge permits one row. For example, two names are at one address. Every read shows these rows in `conflicts`. Writes stop until you run `merge`. The `merge` command keeps the newest row of each conflict and closes the other rows, in one revision. Its result lists each choice. Tell the user the choices.
195
+ - `invalid-database` or `unsupported-schema`: tell the user the message. Do not edit the knowledge files.
185
196
 
186
197
  A revert makes a new revision. The history stays complete.
187
198
 
@@ -190,3 +201,4 @@ A revert makes a new revision. The history stays complete.
190
201
  - Runtime evidence → c64-emulator
191
202
  - Static evidence → c64-static-analysis
192
203
  - Source from the knowledge → c64-source
204
+ - Names of routines that other programs share → c64-provenance
@@ -1,5 +1,6 @@
1
- // The c64-knowledge script: reads and writes .c64-re-tools/knowledge.db in the
2
- // project directory (the working directory) and prints one compact JSON result.
1
+ // The c64-knowledge script: reads and writes .c64-re-tools/knowledge/ in the
2
+ // project folder (--project, or the one found from the working directory) and
3
+ // prints one compact JSON result.
3
4
 
4
5
  import { createHash } from "node:crypto";
5
6
  import { readFileSync } from "node:fs";
@@ -9,6 +10,7 @@ import { parseArgs } from "node:util";
9
10
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
10
11
  import { ImageError, readImage } from "#src/host-client/image.ts";
11
12
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
13
+ import { CONFLICT_RULES, conflicts, type Conflict } from "#src/knowledge/conflicts.ts";
12
14
  import { KnowledgeError, openForRead, withWrite } from "#src/knowledge/database.ts";
13
15
  import { operandText } from "#src/knowledge/expression.ts";
14
16
  import { historyAt, historyNamed, revision, revisionNote, revisions, type HistoryEntry } from "#src/knowledge/history.ts";
@@ -53,6 +55,7 @@ import {
53
55
  addReference,
54
56
  applyBatch,
55
57
  classifyRegion,
58
+ mergeConflicts,
56
59
  removeComment,
57
60
  removeModule,
58
61
  removeOperand,
@@ -66,6 +69,7 @@ import {
66
69
  unclassifyRegion,
67
70
  type BatchOperation,
68
71
  type Evidence,
72
+ type MergeChoice,
69
73
  type OperandImage,
70
74
  type WriteContext,
71
75
  } from "#src/knowledge/write.ts";
@@ -86,8 +90,8 @@ Read (no database needed):
86
90
  operands [--start A --end A --origin O] operand expressions of instructions
87
91
  spaces the address spaces that hold knowledge
88
92
  history <address> | history --name <name>
89
- revisions [--limit N --before N]
90
- revision <id>
93
+ revisions [--limit N --before R]
94
+ revision <revision>
91
95
  export --out <file> [--start A --end A --origin O] all current knowledge to a project file
92
96
 
93
97
  Every command takes --space S: c64 (the default), drive8 for code in the 1541
@@ -97,7 +101,7 @@ own space.
97
101
  Read option --origin O: user, llm, dxa, ghidra, heuristic, semantic (user and
98
102
  llm) or analyzer (dxa, ghidra and heuristic), or a comma list. search takes it too.
99
103
 
100
- Write (one revision each; creates the database when needed):
104
+ Write (one revision each; creates .c64-re-tools/knowledge/ when needed):
101
105
  rename <address> <name> [--kind K]
102
106
  remove-symbol <address>
103
107
  classify <start> <end> <type> [--element N]
@@ -117,6 +121,9 @@ Write (one revision each; creates the database when needed):
117
121
  or PRG); give a negative offset as --offset=-1
118
122
  unoperand <address>
119
123
  revert <revision>
124
+ merge decide the conflicts that a git merge of two
125
+ computers' work left: of the rows of one conflict,
126
+ the newest stays; one revision for all
120
127
  apply <file> many writes from a JSON file ('-' reads standard input),
121
128
  all as one revision, or none when one is refused;
122
129
  operand writes need --image <file>
@@ -125,10 +132,12 @@ Write options:
125
132
  --reason <text> why, kept with the revision
126
133
  --evidence <kind:ref> what the change rests on: coverage:<file>, test:<file>,
127
134
  file:<file> or note:<text> (repeatable)
128
- --expect-revision <N> refuse the write if knowledge changed after revision N
135
+ --expect-revision <R> refuse the write if knowledge changed after revision R
129
136
  --origin user|llm who decided (default llm)
130
137
 
131
- Addresses are $ and four hex digits; quote them in a shell: '$2100'.`;
138
+ Addresses are $ and four hex digits; quote them in a shell: '$2100'.
139
+ A revision is its id, for example 20261015T214501.123-k3f9qz, or a start of
140
+ the id that only one revision has; 0 is the knowledge before every revision.`;
132
141
 
133
142
  function address(value: string | undefined, what = "address"): number {
134
143
  if (value === undefined) throw new UsageError(`${what} is missing`);
@@ -205,9 +214,19 @@ function historyEntry(entry: HistoryEntry) {
205
214
  return { entity: entry.entity, ...row, fromRevision: entry.from, ...(entry.to === undefined ? {} : { toRevision: entry.to }) };
206
215
  }
207
216
 
217
+ /** A conflict of a git merge (D69): what is wrong, and the rows, oldest first. */
218
+ function showConflict(conflict: Conflict) {
219
+ return { conflict: CONFLICT_RULES[conflict.rule], ...spaceOf(conflict), rows: conflict.rows.map(historyEntry) };
220
+ }
221
+
222
+ /** What merge decided for one conflict: the row that stays and the rows it closed. */
223
+ function showChoice(choice: MergeChoice) {
224
+ return { conflict: CONFLICT_RULES[choice.rule], ...spaceOf(choice), kept: historyEntry(choice.kept), closed: choice.closed.map(historyEntry) };
225
+ }
226
+
208
227
  /** The reason of each revision that wrote one of the rows. */
209
- function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<number, string> {
210
- const reasons = new Map<number, string>();
228
+ function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<string, string> {
229
+ const reasons = new Map<string, string>();
211
230
  const query = db?.prepare("SELECT description FROM revisions WHERE id = ?");
212
231
  for (const { revision } of rows) {
213
232
  if (query === undefined || reasons.has(revision)) continue;
@@ -254,7 +273,7 @@ function run(argv: string[]): unknown {
254
273
  const space = values.space ?? DEFAULT_SPACE;
255
274
 
256
275
  const read = new Set(["at", "search", "symbols", "regions", "comments", "references", "suppressions", "modules", "operands", "spaces", "history", "revisions", "revision", "export"]);
257
- const write = new Set(["rename", "remove-symbol", "classify", "unclassify", "comment", "uncomment", "reference", "unreference", "module", "unmodule", "operand", "unoperand", "revert", "apply"]);
276
+ const write = new Set(["rename", "remove-symbol", "classify", "unclassify", "comment", "uncomment", "reference", "unreference", "module", "unmodule", "operand", "unoperand", "revert", "apply", "merge"]);
258
277
  // Refuse a mistyped command before anything opens, so no database appears by accident.
259
278
  if (!read.has(command) && !write.has(command)) throw new UsageError(`unknown command: ${command}`);
260
279
  if (read.has(command)) {
@@ -262,83 +281,10 @@ function run(argv: string[]): unknown {
262
281
  if (command === "export" && values.out === undefined) throw new UsageError("export needs --out <file>");
263
282
  const db = openForRead();
264
283
  try {
265
- switch (command) {
266
- case "at": {
267
- const known = at(db, address(args[0]), space);
268
- return {
269
- ...(space === DEFAULT_SPACE ? {} : { space }),
270
- address: formatC64Address(known.address),
271
- ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
272
- ...(known.region === undefined ? {} : { region: region(known.region) }),
273
- comments: known.comments.map(comment),
274
- referencesFrom: known.referencesFrom.map(reference),
275
- referencesTo: known.referencesTo.map(reference),
276
- ...(known.operand === undefined ? {} : { operand: operand(known.operand) }),
277
- ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
278
- revision: known.revision,
279
- };
280
- }
281
- case "search": {
282
- if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
283
- const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
284
- return { symbols: found.symbols.map(symbol), comments: found.comments.map(comment) };
285
- }
286
- case "symbols":
287
- return { symbols: listSymbols(db, range, values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind"), origins, space).map(symbol) };
288
- case "regions":
289
- return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
290
- case "comments":
291
- return { comments: listComments(db, range, origins, space).map(comment) };
292
- case "references":
293
- return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
294
- case "export": {
295
- const exported = {
296
- revision: currentRevision(db),
297
- ...(space === DEFAULT_SPACE ? {} : { space }),
298
- symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
299
- regions: listRegions(db, range, undefined, origins, space).map(region),
300
- comments: listComments(db, range, origins, space).map(comment),
301
- references: listReferences(db, range, undefined, origins, space).map(reference),
302
- suppressions: listSuppressions(db, range, space).map(suppression),
303
- modules: listModules(db, range, origins, space).map(module),
304
- operands: listOperands(db, range, origins, space).map(operand),
305
- };
306
- const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
307
- writeProjectFile(values.out!, bytes, { what: "--out" });
308
- return {
309
- out: values.out,
310
- bytes: bytes.length,
311
- counts: { symbols: exported.symbols.length, regions: exported.regions.length, comments: exported.comments.length, references: exported.references.length, suppressions: exported.suppressions.length, modules: exported.modules.length, operands: exported.operands.length },
312
- revision: exported.revision,
313
- };
314
- }
315
- case "suppressions": {
316
- const rows = listSuppressions(db, range, space);
317
- const reasons = reasonsOf(db, rows);
318
- return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
319
- }
320
- case "modules":
321
- return { modules: listModules(db, range, origins, space).map(module) };
322
- case "operands":
323
- return { operands: listOperands(db, range, origins, space).map(operand) };
324
- case "spaces":
325
- return { spaces: listSpaces(db) };
326
- case "history":
327
- // Each row with the reason and the evidence of the revision that wrote it (F-26).
328
- return {
329
- history: (values.name === undefined ? historyAt(db, address(args[0]), space) : historyNamed(db, values.name, space)).map((entry) => ({ ...historyEntry(entry), ...revisionNote(db, entry.from) })),
330
- };
331
- case "revisions": {
332
- const options: { limit?: number; before?: number } = {};
333
- if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
334
- if (values.before !== undefined) options.before = integer(values.before, "--before");
335
- return { revisions: revisions(db, options) };
336
- }
337
- default: {
338
- const changes = revision(db, integer(args[0], "revision"));
339
- return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
340
- }
341
- }
284
+ const answer = readAnswer(db, command, args, values, range, origins, space);
285
+ const found = conflicts(db);
286
+ // A git merge can leave conflicts: every read shows them until merge decides them (D69).
287
+ return found.length === 0 ? answer : { ...answer, conflicts: found.map(showConflict) };
342
288
  } finally {
343
289
  db?.close();
344
290
  }
@@ -346,7 +292,7 @@ function run(argv: string[]): unknown {
346
292
 
347
293
  const context: WriteContext = { origin: values.origin === undefined ? "llm" : oneOf(["user", "llm"] as const, values.origin, "--origin") };
348
294
  if (values.reason !== undefined) context.description = values.reason;
349
- if (values["expect-revision"] !== undefined) context.expectedRevision = integer(values["expect-revision"], "--expect-revision");
295
+ if (values["expect-revision"] !== undefined) context.expectedRevision = values["expect-revision"];
350
296
  if (values.evidence !== undefined) context.evidence = values.evidence.map(evidenceOf);
351
297
  // Every argument is checked before the database opens: a refused write creates no database.
352
298
  if (values.title !== undefined && command !== "module") throw new UsageError("--title works only with module");
@@ -363,6 +309,90 @@ function run(argv: string[]): unknown {
363
309
  return withWrite(operation);
364
310
  }
365
311
 
312
+ type ReadValues = { kind?: string | undefined; type?: string | undefined; name?: string | undefined; limit?: string | undefined; before?: string | undefined; out?: string | undefined };
313
+
314
+ /** The answer of one read command. */
315
+ function readAnswer(db: DatabaseSync | undefined, command: string, args: string[], values: ReadValues, range: { start: number; end: number }, origins: Origin[] | undefined, space: string): Record<string, unknown> {
316
+ switch (command) {
317
+ case "at": {
318
+ const known = at(db, address(args[0]), space);
319
+ return {
320
+ ...(space === DEFAULT_SPACE ? {} : { space }),
321
+ address: formatC64Address(known.address),
322
+ ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
323
+ ...(known.region === undefined ? {} : { region: region(known.region) }),
324
+ comments: known.comments.map(comment),
325
+ referencesFrom: known.referencesFrom.map(reference),
326
+ referencesTo: known.referencesTo.map(reference),
327
+ ...(known.operand === undefined ? {} : { operand: operand(known.operand) }),
328
+ ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
329
+ revision: known.revision,
330
+ };
331
+ }
332
+ case "search": {
333
+ if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
334
+ const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
335
+ return { symbols: found.symbols.map(symbol), comments: found.comments.map(comment) };
336
+ }
337
+ case "symbols":
338
+ return { symbols: listSymbols(db, range, values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind"), origins, space).map(symbol) };
339
+ case "regions":
340
+ return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
341
+ case "comments":
342
+ return { comments: listComments(db, range, origins, space).map(comment) };
343
+ case "references":
344
+ return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
345
+ case "export": {
346
+ const exported = {
347
+ revision: currentRevision(db),
348
+ ...(space === DEFAULT_SPACE ? {} : { space }),
349
+ symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
350
+ regions: listRegions(db, range, undefined, origins, space).map(region),
351
+ comments: listComments(db, range, origins, space).map(comment),
352
+ references: listReferences(db, range, undefined, origins, space).map(reference),
353
+ suppressions: listSuppressions(db, range, space).map(suppression),
354
+ modules: listModules(db, range, origins, space).map(module),
355
+ operands: listOperands(db, range, origins, space).map(operand),
356
+ };
357
+ const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
358
+ writeProjectFile(values.out!, bytes, { what: "--out" });
359
+ return {
360
+ out: values.out,
361
+ bytes: bytes.length,
362
+ counts: { symbols: exported.symbols.length, regions: exported.regions.length, comments: exported.comments.length, references: exported.references.length, suppressions: exported.suppressions.length, modules: exported.modules.length, operands: exported.operands.length },
363
+ revision: exported.revision,
364
+ };
365
+ }
366
+ case "suppressions": {
367
+ const rows = listSuppressions(db, range, space);
368
+ const reasons = reasonsOf(db, rows);
369
+ return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
370
+ }
371
+ case "modules":
372
+ return { modules: listModules(db, range, origins, space).map(module) };
373
+ case "operands":
374
+ return { operands: listOperands(db, range, origins, space).map(operand) };
375
+ case "spaces":
376
+ return { spaces: listSpaces(db) };
377
+ case "history":
378
+ // Each row with the reason and the evidence of the revision that wrote it (F-26).
379
+ return {
380
+ history: (values.name === undefined ? historyAt(db, address(args[0]), space) : historyNamed(db, values.name, space)).map((entry) => ({ ...historyEntry(entry), ...revisionNote(db, entry.from) })),
381
+ };
382
+ case "revisions": {
383
+ const options: { limit?: number; before?: string } = {};
384
+ if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
385
+ if (values.before !== undefined) options.before = values.before;
386
+ return { revisions: revisions(db, options) };
387
+ }
388
+ default: {
389
+ if (args[0] === undefined) throw new UsageError("revision is missing");
390
+ const changes = revision(db, args[0]);
391
+ return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
392
+ }
393
+ }
394
+ }
395
+
366
396
  /** --origin for reads: one origin, semantic (user and llm), analyzer (dxa and ghidra), or a comma list. */
367
397
  function originFilter(value: string): Origin[] {
368
398
  const origins = new Set<Origin>();
@@ -601,8 +631,17 @@ function writeOperation(command: string, args: string[], kind: string | undefine
601
631
  const request = { space, address: address(args[0]) };
602
632
  return (db) => ({ revision: removeOperand(db, context, request).revision });
603
633
  }
634
+ case "merge": {
635
+ if (args.length > 0) throw new UsageError("merge takes no arguments");
636
+ const platform = new Map(platformNames().map((entry) => [entry.name, entry.address]));
637
+ return (db) => {
638
+ const merged = mergeConflicts(db, context, platform);
639
+ return { revision: merged.revision, choices: merged.current.map(showChoice) };
640
+ };
641
+ }
604
642
  case "revert": {
605
- const request = { revision: integer(args[0], "revision") };
643
+ if (args[0] === undefined) throw new UsageError("revision is missing");
644
+ const request = { revision: args[0] };
606
645
  return (db) => ({ revision: revert(db, context, request).revision });
607
646
  }
608
647
  default:
@@ -610,4 +649,4 @@ function writeOperation(command: string, args: string[], kind: string | undefine
610
649
  }
611
650
  }
612
651
 
613
- await runScript(() => run(process.argv.slice(2)), { usage: USAGE, refusals: [KnowledgeError] });
652
+ await runScript((argv) => run(argv), { usage: USAGE, refusals: [KnowledgeError] });