@maxiaochao/pi-toolkit 0.9.3 → 0.9.6

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 (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +40 -90
  3. package/extensions/{btw.ts → btw/index.ts} +1 -1
  4. package/extensions/{tests/btw-history.mjs → btw/tests/history.mjs} +1 -1
  5. package/package.json +19 -10
  6. package/packages/cache-export/CHANGELOG.md +5 -0
  7. package/packages/cache-export/README.md +70 -0
  8. package/packages/cache-export/TESTING.md +17 -0
  9. package/packages/cache-export/docs/demo.gif +0 -0
  10. package/packages/cache-export/docs/screenshot.png +0 -0
  11. package/{extensions/cache-export/index.ts → packages/cache-export/extensions/cache-export.ts} +1 -1
  12. package/packages/cache-export/package.json +22 -0
  13. package/{extensions/cache-export → packages/cache-export/src}/render.ts +1 -1
  14. package/{extensions → packages}/cache-export/tests/e2e-browser.mjs +2 -2
  15. package/{extensions → packages}/cache-export/tests/run-tests.mjs +2 -2
  16. package/packages/codex-edit/CHANGELOG.md +47 -0
  17. package/packages/codex-edit/README.md +130 -0
  18. package/packages/codex-edit/TESTING.md +51 -0
  19. package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
  20. package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
  21. package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
  22. package/packages/codex-edit/benchmark/prepare.mjs +181 -0
  23. package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
  24. package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
  25. package/packages/codex-edit/benchmark/schedule.mjs +19 -0
  26. package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
  27. package/{extensions → packages}/codex-edit/codex-edit-explainer.html +43 -22
  28. package/packages/codex-edit/docs/demo.gif +0 -0
  29. package/packages/codex-edit/docs/screenshot.png +0 -0
  30. package/packages/codex-edit/docs/test-report.html +181 -0
  31. package/packages/codex-edit/docs/test-report.md +242 -0
  32. package/packages/codex-edit/docs/test-summary.md +9 -0
  33. package/{extensions/codex-edit/index.ts → packages/codex-edit/extensions/codex-edit.ts} +7 -5
  34. package/packages/codex-edit/package.json +54 -0
  35. package/{extensions/codex-edit → packages/codex-edit/src}/parser.ts +27 -5
  36. package/{extensions → packages}/codex-edit/summary.md +1 -1
  37. package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
  38. package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
  39. package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
  40. package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
  41. package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
  42. package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
  43. package/packages/codex-edit/tests/package.test.mjs +51 -0
  44. package/packages/codex-edit/tests/parser.test.mjs +308 -0
  45. package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
  46. package/packages/tiny-subagent/CHANGELOG.md +12 -0
  47. package/packages/tiny-subagent/README.md +128 -0
  48. package/packages/tiny-subagent/TESTING.md +20 -0
  49. package/packages/tiny-subagent/agents/worker.md +9 -0
  50. package/packages/tiny-subagent/docs/demo.gif +0 -0
  51. package/packages/tiny-subagent/docs/screenshot.png +0 -0
  52. package/packages/tiny-subagent/extensions/tiny-subagent.ts +228 -0
  53. package/packages/tiny-subagent/package.json +44 -0
  54. package/packages/tiny-subagent/src/personas.ts +110 -0
  55. package/packages/tiny-subagent/src/runner.ts +319 -0
  56. package/packages/tiny-subagent/tests/run-tests.mjs +111 -0
  57. package/packages/todo/CHANGELOG.md +5 -0
  58. package/packages/todo/README.md +63 -0
  59. package/packages/todo/TESTING.md +20 -0
  60. package/packages/todo/docs/demo.gif +0 -0
  61. package/packages/todo/docs/screenshot.png +0 -0
  62. package/{extensions → packages/todo/extensions}/todo.ts +1 -1
  63. package/packages/todo/package.json +25 -0
  64. package/{extensions → packages/todo}/tests/todo-state.mjs +1 -1
  65. package/packages/worktree/CHANGELOG.md +5 -0
  66. package/packages/worktree/README.md +60 -0
  67. package/packages/worktree/TESTING.md +21 -0
  68. package/packages/worktree/docs/demo.gif +0 -0
  69. package/packages/worktree/docs/screenshot.png +0 -0
  70. package/packages/worktree/package.json +20 -0
  71. package/packages/worktree/scripts/install.mjs +26 -0
  72. package/packages/worktree/tests/install.test.mjs +25 -0
  73. package/packages/worktree/tests/run-tests.sh +46 -0
  74. package/scripts/install-agents.mjs +8 -18
  75. package/extensions/codex-edit/README.md +0 -53
  76. package/extensions/codex-edit/tests/package.test.mjs +0 -38
  77. package/extensions/codex-edit/tests/parser.test.mjs +0 -89
  78. /package/extensions/{btw-scroll.ts → btw/scroll.ts} +0 -0
  79. /package/{extensions/cache-export → packages/cache-export/src}/tau-assets.ts +0 -0
  80. /package/{extensions → packages}/codex-edit/config.json +0 -0
  81. /package/{extensions/codex-edit → packages/codex-edit/src}/model-routing.ts +0 -0
  82. /package/{extensions → packages/todo/src}/todo-state.ts +0 -0
  83. /package/{bin → packages/worktree/bin}/pi-worktree +0 -0
  84. /package/{bin → packages/worktree/bin}/pi-worktree-completion.bash +0 -0
  85. /package/{skills → packages/worktree/skills}/pi-worktree/SKILL.md +0 -0
@@ -0,0 +1,128 @@
1
+ // Frozen semantic baseline from @maxiaochao/pi-codex-edit v0.1.7.
2
+ // Source blob: codex-edit-v0.1.7:packages/codex-edit/src/parser.ts
3
+ // Git blob ID: e2896b59d40b5f6f01b3a4a7918476f4acc28cd8
4
+ // Keep this file unchanged; add cases to comparison-cases.mjs instead.
5
+
6
+ export function parseLegacyPatch(patch) {
7
+ const lines = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split("\n");
8
+ if (lines[0]?.trim() !== "*** Begin Patch") throw new Error("The first line of the patch must be '*** Begin Patch'");
9
+ if (lines.at(-1)?.trim() !== "*** End Patch") throw new Error("The last line of the patch must be '*** End Patch'");
10
+ const operations = [];
11
+ let index = 1;
12
+ while (index < lines.length - 1) {
13
+ const line = lines[index];
14
+ if (line.startsWith("*** Add File: ")) {
15
+ const path = line.slice("*** Add File: ".length);
16
+ index += 1;
17
+ const content = [];
18
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
19
+ if (!lines[index].startsWith("+")) throw new Error(`Invalid add line ${index + 1}`);
20
+ content.push(lines[index].slice(1));
21
+ index += 1;
22
+ }
23
+ if (content.length === 0) throw new Error(`Add file hunk for path '${path}' is empty`);
24
+ operations.push({ action: "add", path, content: `${content.join("\n")}\n` });
25
+ continue;
26
+ }
27
+ if (line.startsWith("*** Delete File: ")) {
28
+ const path = line.slice("*** Delete File: ".length);
29
+ if (!path) throw new Error(`Delete file hunk on line ${index + 1} has an empty path`);
30
+ operations.push({ action: "delete", path });
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (line.startsWith("*** Update File: ")) {
35
+ const path = line.slice("*** Update File: ".length);
36
+ index += 1;
37
+ let moveTo;
38
+ if (lines[index]?.startsWith("*** Move to: ")) {
39
+ moveTo = lines[index].slice("*** Move to: ".length);
40
+ index += 1;
41
+ }
42
+ const chunks = [];
43
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
44
+ let context;
45
+ if (lines[index] === "@@" || lines[index].startsWith("@@ ")) {
46
+ context = lines[index].slice(2).trim() || undefined;
47
+ index += 1;
48
+ }
49
+ const oldLines = [];
50
+ const newLines = [];
51
+ const contextLineIndices = [];
52
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ") && !lines[index].startsWith("@@")) {
53
+ const change = lines[index];
54
+ const marker = change[0];
55
+ if (!["+", "-", " "].includes(marker)) throw new Error(`Invalid update line ${index + 1}`);
56
+ const text = change.slice(1);
57
+ if (marker === "+") newLines.push(text);
58
+ else if (marker === "-") oldLines.push(text);
59
+ else {
60
+ contextLineIndices.push([oldLines.length, newLines.length]);
61
+ oldLines.push(text);
62
+ newLines.push(text);
63
+ }
64
+ index += 1;
65
+ }
66
+ const endOfFile = lines[index] === "*** End of File";
67
+ if (endOfFile) index += 1;
68
+ if (oldLines.length === 0 && newLines.length === 0) throw new Error(`Empty update chunk on line ${index + 1}`);
69
+ chunks.push({ context, oldLines, newLines, contextLineIndices, endOfFile });
70
+ }
71
+ if (chunks.length === 0) throw new Error(`Update file hunk for path '${path}' is empty`);
72
+ operations.push({ action: "update", path, moveTo, chunks });
73
+ continue;
74
+ }
75
+ throw new Error(`Invalid hunk header on line ${index + 1}: ${line}`);
76
+ }
77
+ if (operations.length === 0) throw new Error("Patch contains no file operations");
78
+ const targets = operations.flatMap((operation) => [operation.path, operation.moveTo].filter(Boolean));
79
+ if (new Set(targets).size !== targets.length) throw new Error("Multiple operations target the same path");
80
+ return operations;
81
+ }
82
+
83
+ function findCandidates(lines, pattern, start, eof, compare) {
84
+ const candidates = [];
85
+ for (let index = start; index + pattern.length <= lines.length; index += 1) {
86
+ if (pattern.every((line, offset) => compare(lines[index + offset], line))) candidates.push(index);
87
+ }
88
+ candidates.sort((left, right) => eof ? right - left : left - right);
89
+ return candidates;
90
+ }
91
+
92
+ function locate(lines, pattern, start, eof, label) {
93
+ if (pattern.length === 0) return Math.min(start, lines.length);
94
+ const comparisons = [
95
+ (left, right) => left === right,
96
+ (left, right) => left.trimEnd() === right.trimEnd(),
97
+ (left, right) => left.trim() === right.trim(),
98
+ ];
99
+ for (const compare of comparisons) {
100
+ const candidates = findCandidates(lines, pattern, start, eof, compare);
101
+ if (candidates.length > 0) return candidates[0];
102
+ }
103
+ throw new Error(`Failed to find expected lines${label ? ` in ${label}` : ""}: ${pattern.join("\\n")}`);
104
+ }
105
+
106
+ export function applyLegacyUpdate(content, chunks, path) {
107
+ const bom = content.startsWith("\ufeff") ? "\ufeff" : "";
108
+ const withoutBom = bom ? content.slice(1) : content;
109
+ const ending = withoutBom.includes("\r\n") ? "\r\n" : "\n";
110
+ const normalized = withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
111
+ const hadFinalNewline = normalized.endsWith("\n");
112
+ const lines = normalized === "" ? [] : normalized.split("\n");
113
+ if (hadFinalNewline) lines.pop();
114
+ const replacements = [];
115
+ let cursor = 0;
116
+ for (const chunk of chunks) {
117
+ if (chunk.context !== undefined) cursor = locate(lines, [chunk.context], cursor, false, path) + 1;
118
+ const start = locate(lines, chunk.oldLines, cursor, chunk.endOfFile, path);
119
+ replacements.push({ start, length: chunk.oldLines.length, next: chunk.newLines });
120
+ cursor = start + chunk.oldLines.length;
121
+ }
122
+ const output = [...lines];
123
+ for (const replacement of [...replacements].reverse()) output.splice(replacement.start, replacement.length, ...replacement.next);
124
+ let next = output.join("\n");
125
+ if (hadFinalNewline || next.length > 0) next += "\n";
126
+ if (ending === "\r\n") next = next.replace(/\n/g, "\r\n");
127
+ return bom + next;
128
+ }
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createEditToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import registerCodexEdit from "../extensions/codex-edit.ts";
7
+ import { comparisonCases } from "./comparison-cases.mjs";
8
+
9
+ async function writeSnapshot(root, snapshot) {
10
+ for (const [path, content] of Object.entries(snapshot)) {
11
+ const absolute = join(root, path);
12
+ await mkdir(dirname(absolute), { recursive: true });
13
+ await writeFile(absolute, content, "utf8");
14
+ }
15
+ }
16
+
17
+ async function readSnapshot(root, relative = "") {
18
+ const snapshot = {};
19
+ for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
20
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
21
+ if (entry.isDirectory()) Object.assign(snapshot, await readSnapshot(root, path));
22
+ else snapshot[path] = await readFile(join(root, path), "utf8");
23
+ }
24
+ return snapshot;
25
+ }
26
+
27
+ async function runNativeCase(testCase) {
28
+ if (testCase.outcomes.native === "unsupported") {
29
+ assert.equal(testCase.nativeCalls, null, `${testCase.id}: unsupported native case must not invent edit calls`);
30
+ return "unsupported";
31
+ }
32
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-native-"));
33
+ try {
34
+ await writeSnapshot(workspace, testCase.input);
35
+ const edit = createEditToolDefinition(workspace);
36
+ try {
37
+ for (const [index, call] of testCase.nativeCalls.entries()) {
38
+ await edit.execute(`native-${index}`, call, undefined, undefined, { cwd: workspace });
39
+ }
40
+ if (testCase.outcomes.native !== "pass") {
41
+ assert.fail(`${testCase.id}: native edit unexpectedly succeeded`);
42
+ }
43
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
44
+ return "pass";
45
+ } catch (error) {
46
+ if (testCase.outcomes.native === "pass") throw error;
47
+ return "fail";
48
+ }
49
+ } finally {
50
+ await rm(workspace, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ async function getCodexTool() {
55
+ let tool;
56
+ await registerCodexEdit({
57
+ registerTool(definition) { tool = definition; },
58
+ on() {},
59
+ getActiveTools() { return []; },
60
+ });
61
+ assert.ok(tool, "Codex Edit extension did not register apply_patch");
62
+ return tool;
63
+ }
64
+
65
+ async function runCodexCase(tool, testCase) {
66
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-local-"));
67
+ try {
68
+ await writeSnapshot(workspace, testCase.input);
69
+ try {
70
+ await tool.execute("codex-local", { patch: testCase.patch }, undefined, undefined, {
71
+ cwd: workspace,
72
+ hasUI: false,
73
+ });
74
+ if (testCase.outcomes.local !== "pass") {
75
+ assert.fail(`${testCase.id}: local Codex Edit unexpectedly succeeded`);
76
+ }
77
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
78
+ return "pass";
79
+ } catch (error) {
80
+ if (testCase.outcomes.local === "pass") throw error;
81
+ assert.deepEqual(
82
+ await readSnapshot(workspace),
83
+ testCase.input,
84
+ `${testCase.id}: failed Codex patch must not partially mutate the workspace`,
85
+ );
86
+ return "fail";
87
+ }
88
+ } finally {
89
+ await rm(workspace, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ export default async function () {
94
+ const codexTool = await getCodexTool();
95
+ const results = [];
96
+ for (const testCase of comparisonCases) {
97
+ const native = await runNativeCase(testCase);
98
+ const codex = await runCodexCase(codexTool, testCase);
99
+ results.push({ id: testCase.id, native, codex });
100
+ }
101
+ assert.equal(results.length, comparisonCases.length);
102
+ assert.equal(results.filter(({ native }) => native === "pass").length, 5);
103
+ assert.equal(results.filter(({ native }) => native === "fail").length, 2);
104
+ assert.equal(results.filter(({ native }) => native === "unsupported").length, 5);
105
+ assert.equal(results.filter(({ codex }) => codex === "pass").length, 7);
106
+ assert.equal(results.filter(({ codex }) => codex === "fail").length, 5);
107
+ for (const result of results) console.log(`${result.id}: native=${result.native}, codex=${result.codex}`);
108
+ console.log(`Native edit vs local Codex Edit: ${results.length} comparison cases passed`);
109
+ }
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import test from "node:test";
5
+ import { modelIsAllowed } from "../src/model-routing.ts";
6
+
7
+ const packageDir = dirname(dirname(new URL(import.meta.url).pathname));
8
+
9
+ test("package points Pi at the extension and config is reachable from it", async () => {
10
+ const manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
11
+ assert.deepEqual(manifest.pi.extensions, ["./extensions/codex-edit.ts"]);
12
+ const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
13
+ assert.match(source, /new URL\("\.\.\/config\.json", import\.meta\.url\)/);
14
+ assert.match(source, /model-routing\.ts/);
15
+ assert.match(source, /parameters: Type\.Object\(\{\s*patch: Type\.String/);
16
+ assert.match(source, /applyUpdateGroups\(before, operation\.chunkGroups, operation\.path\)/);
17
+ const config = JSON.parse(await readFile(join(packageDir, "config.json"), "utf8"));
18
+ assert.equal(config.enabled, true);
19
+ assert.deepEqual(config.models, ["gpt-5.6-*"]);
20
+ });
21
+
22
+ test("matches allowed model IDs with globs independently of provider and grammar support", () => {
23
+ const settings = { enabled: true, models: ["gpt-5.6-*", "my-codex-model"] };
24
+ const baseModel = {
25
+ id: "gpt-5.6-terra",
26
+ api: "openai-codex-responses",
27
+ compat: { supportsOpenAIGrammarTools: true },
28
+ };
29
+ assert.equal(modelIsAllowed({ ...baseModel, provider: "openai-codex" }, settings), true);
30
+ assert.equal(modelIsAllowed({ ...baseModel, provider: "another-provider" }, settings), true);
31
+ assert.equal(modelIsAllowed({ ...baseModel, compat: { supportsOpenAIGrammarTools: false } }, settings), true);
32
+ assert.equal(
33
+ modelIsAllowed(
34
+ { ...baseModel, api: "openai-responses", compat: { sessionAffinityFormat: "openai-nosession" } },
35
+ settings,
36
+ ),
37
+ true,
38
+ );
39
+ assert.equal(modelIsAllowed({ ...baseModel, api: "openai-completions" }, settings), false);
40
+ assert.equal(modelIsAllowed({ ...baseModel, id: "gpt-5.5" }, settings), false);
41
+ assert.equal(modelIsAllowed({ ...baseModel, id: "my-codex-model" }, settings), true);
42
+ assert.equal(modelIsAllowed({ ...baseModel, id: "my-codex-model-v2" }, settings), false);
43
+ assert.equal(modelIsAllowed({ ...baseModel, id: "GPT-5.6-terra" }, settings), false);
44
+ assert.equal(modelIsAllowed(baseModel, { enabled: false, models: ["gpt-5.6-*"] }), false);
45
+ });
46
+
47
+ test("execution keeps a resolved-path conflict guard after parser coalescing", async () => {
48
+ const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
49
+ assert.match(source, /new Set\(targets\)\.size !== targets\.length/);
50
+ assert.match(source, /Multiple patch operations target the same resolved path/);
51
+ });
@@ -0,0 +1,308 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { applyUpdate, applyUpdateGroups, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
7
+
8
+ test("parses add, delete, update, and move operations", () => {
9
+ const operations = parsePatch(`*** Begin Patch
10
+ *** Add File: added.txt
11
+ +hello
12
+ *** Delete File: removed.txt
13
+ *** Update File: old.txt
14
+ *** Move to: new.txt
15
+ @@
16
+ -old
17
+ +new
18
+ *** End Patch`);
19
+ assert.deepEqual(operations.map((operation) => operation.action), ["add", "delete", "update"]);
20
+ assert.equal(operations[0].content, "hello\n");
21
+ assert.equal(operations[2].moveTo, "new.txt");
22
+ assert.deepEqual(operations[2].chunks[0].oldLines, ["old"]);
23
+ assert.deepEqual(operations[2].chunks[0].newLines, ["new"]);
24
+ });
25
+
26
+ test("rejects malformed patches and duplicate targets", () => {
27
+ assert.throws(() => parsePatch("bad"), /first line/);
28
+ assert.throws(() => parsePatch(`*** Begin Patch
29
+ *** Add File: same.txt
30
+ +x
31
+ *** Add File: same.txt
32
+ +y
33
+ *** End Patch`), /same path/);
34
+ });
35
+
36
+ test("applies multiple chunks using context and preserves BOM and CRLF", () => {
37
+ const before = "\ufefffunction run() {\r\n const value = 1;\r\n return value;\r\n}\r\n";
38
+ const operations = parsePatch(`*** Begin Patch
39
+ *** Update File: src/run.js
40
+ @@ function run() {
41
+ - const value = 1;
42
+ + const value = 2;
43
+ @@
44
+ - return value;
45
+ + return value + 1;
46
+ *** End Patch`);
47
+ const after = applyUpdate(before, operations[0].chunks, "src/run.js");
48
+ assert.equal(after, "\ufefffunction run() {\r\n const value = 2;\r\n return value + 1;\r\n}\r\n");
49
+ });
50
+
51
+ test("supports insertion chunks and EOF matching", () => {
52
+ const operations = parsePatch(`*** Begin Patch
53
+ *** Update File: file.txt
54
+ @@
55
+ +first
56
+ *** End of File
57
+ *** End Patch`);
58
+ assert.equal(applyUpdate("", operations[0].chunks, "file.txt"), "first\n");
59
+ });
60
+
61
+ test("uses the first matching region at or after the cursor", () => {
62
+ const operations = parsePatch(`*** Begin Patch
63
+ *** Update File: file.txt
64
+ @@
65
+ -value
66
+ +changed
67
+ *** End Patch`);
68
+ assert.equal(applyUpdate("value\nvalue\n", operations[0].chunks, "file.txt"), "changed\nvalue\n");
69
+ });
70
+
71
+ test("uses earlier hunks to advance the cursor through repeated HTML regions", () => {
72
+ const operations = parsePatch(`*** Begin Patch
73
+ *** Update File: page.html
74
+ @@
75
+ -old
76
+ +new
77
+ @@
78
+ </section>
79
+ +<section id="inserted"></section>
80
+ <section>
81
+ *** End Patch`);
82
+ const before = `<header>
83
+ old
84
+ </header>
85
+ <section>first</section>
86
+ </section>
87
+ <section>
88
+ one
89
+ </section>
90
+ <section>
91
+ two
92
+ </section>
93
+ `;
94
+ const after = `<header>
95
+ new
96
+ </header>
97
+ <section>first</section>
98
+ </section>
99
+ <section id="inserted"></section>
100
+ <section>
101
+ one
102
+ </section>
103
+ <section>
104
+ two
105
+ </section>
106
+ `;
107
+ assert.equal(applyUpdate(before, operations[0].chunks, "page.html"), after);
108
+ });
109
+
110
+ test("applies a hunk at the first repeated Markdown separator after its cursor", () => {
111
+ const operations = parsePatch(`*** Begin Patch
112
+ *** Update File: SKILL.md
113
+ @@
114
+ -description: old
115
+ +description: new
116
+ @@
117
+ ---
118
+ +Inserted after frontmatter.
119
+ *** End Patch`);
120
+ const before = `---
121
+ name: demo
122
+ description: old
123
+ ---
124
+
125
+ # Demo
126
+
127
+ ---
128
+ Footer
129
+ `;
130
+ const after = `---
131
+ name: demo
132
+ description: new
133
+ ---
134
+ Inserted after frontmatter.
135
+
136
+ # Demo
137
+
138
+ ---
139
+ Footer
140
+ `;
141
+ assert.equal(applyUpdate(before, operations[0].chunks, "SKILL.md"), after);
142
+ });
143
+
144
+ test("parses multiple file operations used by structural refactors", () => {
145
+ const operations = parsePatch(`*** Begin Patch
146
+ *** Update File: extensions/tool.ts
147
+ *** Move to: packages/tool/extensions/tool.ts
148
+ @@
149
+ -export const value = 1;
150
+ +export const value = 2;
151
+ *** Add File: extensions/tool-wrapper.ts
152
+ +export { value } from "../packages/tool/extensions/tool.ts";
153
+ *** Delete File: extensions/tool.test.ts
154
+ *** End Patch`);
155
+ assert.deepEqual(
156
+ operations.map(({ action, path, moveTo }) => ({ action, path, moveTo })),
157
+ [
158
+ { action: "update", path: "extensions/tool.ts", moveTo: "packages/tool/extensions/tool.ts" },
159
+ { action: "add", path: "extensions/tool-wrapper.ts", moveTo: undefined },
160
+ { action: "delete", path: "extensions/tool.test.ts", moveTo: undefined },
161
+ ],
162
+ );
163
+ });
164
+
165
+ test("coalesces repeated updates to one path and preserves operation order", () => {
166
+ const operations = parsePatch(`*** Begin Patch
167
+ *** Update File: src/tool.ts
168
+ @@
169
+ -const value = 1;
170
+ +const value = 2;
171
+ *** Update File: src/tool.ts
172
+ @@
173
+ -const value = 2;
174
+ +const value = 3;
175
+ *** End Patch`);
176
+ assert.equal(operations.length, 1);
177
+ assert.equal(operations[0].path, "src/tool.ts");
178
+ assert.equal(operations[0].chunkGroups.length, 2);
179
+ const after = applyUpdateGroups("const value = 1;\n", operations[0].chunkGroups, "src/tool.ts");
180
+ assert.equal(after, "const value = 3;\n");
181
+ });
182
+
183
+ test("still rejects conflicting repeated targets", () => {
184
+ assert.throws(() => parsePatch(`*** Begin Patch
185
+ *** Update File: src/tool.ts
186
+ @@
187
+ -old
188
+ +new
189
+ *** Delete File: src/tool.ts
190
+ *** End Patch`), /same path/);
191
+ assert.throws(() => parsePatch(`*** Begin Patch
192
+ *** Update File: src/old.ts
193
+ *** Move to: src/new.ts
194
+ @@
195
+ -old
196
+ +new
197
+ *** Add File: src/new.ts
198
+ +replacement
199
+ *** End Patch`), /same path/);
200
+ });
201
+
202
+ test("rejects empty chunks and stale expected lines instead of guessing", () => {
203
+ assert.throws(() => parsePatch(`*** Begin Patch
204
+ *** Update File: src/tool.ts
205
+ @@
206
+ *** End Patch`), /Empty update chunk/);
207
+ const [operation] = parsePatch(`*** Begin Patch
208
+ *** Update File: src/tool.ts
209
+ @@
210
+ -missing line
211
+ +replacement
212
+ *** End Patch`);
213
+ assert.throws(
214
+ () => applyUpdate("actual line\n", operation.chunks, operation.path),
215
+ /Failed to find expected lines in src\/tool\.ts/,
216
+ );
217
+ });
218
+
219
+ test("handles the reported project-scale repeated-file and Markdown shapes", () => {
220
+ const operations = parsePatch(`*** Begin Patch
221
+ *** Update File: skills/pi-worktree/SKILL.md
222
+ @@
223
+ -description: old
224
+ +description: updated
225
+ @@
226
+ ---
227
+ +Release notes follow.
228
+ *** Update File: packages/tiny-subagent/src/tool.ts
229
+ @@
230
+ -const state = "old";
231
+ +const state = "middle";
232
+ *** Update File: packages/tiny-subagent/src/tool.ts
233
+ @@
234
+ -const state = "middle";
235
+ +const state = "new";
236
+ *** Update File: scripts/release-package.sh
237
+ @@
238
+ -ROOT_LEVEL=patch
239
+ +ROOT_LEVEL=minor
240
+ *** Update File: scripts/release-package.sh
241
+ @@
242
+ -ROOT_LEVEL=minor
243
+ +ROOT_LEVEL=patch
244
+ *** End Patch`);
245
+ assert.deepEqual(operations.map((operation) => operation.path), [
246
+ "skills/pi-worktree/SKILL.md",
247
+ "packages/tiny-subagent/src/tool.ts",
248
+ "scripts/release-package.sh",
249
+ ]);
250
+ assert.equal(operations[1].chunkGroups.length, 2);
251
+ assert.equal(operations[2].chunkGroups.length, 2);
252
+
253
+ const skill = `---
254
+ name: pi-worktree
255
+ description: old
256
+ ---
257
+
258
+ # pi-worktree
259
+
260
+ ---
261
+ Footer
262
+ `;
263
+ assert.equal(
264
+ applyUpdateGroups(skill, operations[0].chunkGroups, operations[0].path),
265
+ `---
266
+ name: pi-worktree
267
+ description: updated
268
+ ---
269
+ Release notes follow.
270
+
271
+ # pi-worktree
272
+
273
+ ---
274
+ Footer
275
+ `,
276
+ );
277
+ assert.equal(
278
+ applyUpdateGroups('const state = "old";\n', operations[1].chunkGroups, operations[1].path),
279
+ 'const state = "new";\n',
280
+ );
281
+ assert.equal(
282
+ applyUpdateGroups("ROOT_LEVEL=patch\n", operations[2].chunkGroups, operations[2].path),
283
+ "ROOT_LEVEL=patch\n",
284
+ );
285
+ });
286
+
287
+ test("rejects workspace escapes", () => {
288
+ assert.equal(resolveWorkspacePath("/tmp/project", "/tmp/project/src/a.js"), "/tmp/project/src/a.js");
289
+ assert.throws(() => resolveWorkspacePath("/tmp/project", "/tmp/other/a.js"), /escapes/);
290
+ });
291
+
292
+ test("round-trips a temporary file operation fixture", async () => {
293
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-test-"));
294
+ try {
295
+ const path = join(workspace, "file.txt");
296
+ await writeFile(path, "before\n", "utf8");
297
+ const operations = parsePatch(`*** Begin Patch
298
+ *** Update File: file.txt
299
+ @@
300
+ -before
301
+ +after
302
+ *** End Patch`);
303
+ await writeFile(path, applyUpdate(await readFile(path, "utf8"), operations[0].chunks, "file.txt"), "utf8");
304
+ assert.equal(await readFile(path, "utf8"), "after\n");
305
+ } finally {
306
+ await rm(workspace, { recursive: true, force: true });
307
+ }
308
+ });
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { applyUpdate, parsePatch } from "../src/parser.ts";
4
+ import { comparisonCases } from "./comparison-cases.mjs";
5
+ import { runPatchCase } from "./comparison-harness.mjs";
6
+ import { applyLegacyUpdate, parseLegacyPatch } from "./legacy-parser-v0.1.7.mjs";
7
+
8
+ test("released v0.1.7 versus local parser over the growing comparison corpus", () => {
9
+ const results = [];
10
+ for (const testCase of comparisonCases) {
11
+ const released = runPatchCase(
12
+ testCase,
13
+ parseLegacyPatch,
14
+ applyLegacyUpdate,
15
+ testCase.outcomes.released,
16
+ );
17
+ const local = runPatchCase(testCase, parsePatch, applyUpdate, testCase.outcomes.local);
18
+ results.push({ id: testCase.id, released, local });
19
+ }
20
+
21
+ assert.equal(results.length, comparisonCases.length);
22
+ assert.deepEqual(
23
+ results.find(({ id }) => id === "repeated-update-same-path"),
24
+ { id: "repeated-update-same-path", released: "fail", local: "pass" },
25
+ );
26
+ assert.equal(results.filter(({ released }) => released === "pass").length, 6);
27
+ assert.equal(results.filter(({ released }) => released === "fail").length, 6);
28
+ assert.equal(results.filter(({ local }) => local === "pass").length, 7);
29
+ assert.equal(results.filter(({ local }) => local === "fail").length, 5);
30
+ for (const result of results) console.log(`${result.id}: released=${result.released}, local=${result.local}`);
31
+ });
@@ -0,0 +1,12 @@
1
+ ## 2026-09-30 - v0.1.1
2
+
3
+ - Add standalone testing documentation and real production-runner screenshot/demo media.
4
+ - Align the package manifest and root bundle with the shared workspace source.
5
+
6
+ ## 2026-09-01 - v0.1.0
7
+
8
+ - Add the `tiny_subagents` Pi tool for one focused prompt in a fresh child Pi context.
9
+ - Add package-local `worker.md` personas with model and built-in tool selection.
10
+ - Return structured process, Pi protocol, stderr, stop-reason, exit, signal, and cancellation diagnostics.
11
+ - Preserve oversized stdout, stderr, and final assistant output in private temporary artifacts.
12
+ - Stream child event activity to the TUI and expose persona, prompt, and transcript details when expanded.