diff --git a/skills/hwpod-cli/SKILL.md b/skills/hwpod-cli/SKILL.md index 699a5c93..5420bb75 100644 --- a/skills/hwpod-cli/SKILL.md +++ b/skills/hwpod-cli/SKILL.md @@ -22,7 +22,7 @@ The HWPOD spec is code-agent-side state. The default path is `.hwlab/hwpod-spec. ## Commands - Inspect the HWPOD path: `hwpod inspect --spec .hwlab/hwpod-spec.yaml`. -- Workspace read/edit: use `hwpod workspace ls|cat|read|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `--file` and `--remote-path` are accepted as path aliases. +- Workspace read/edit: use `hwpod workspace ls|cat|read|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands. - Build/download/reset: use `hwpod build`, `hwpod download`, and `hwpod reset`; the compiler must translate these through existing file ops and `cmd.run` where possible. - Keil async status: after build/download returns a job id, use separate short `hwpod job status --spec .hwlab/hwpod-spec.yaml` calls instead of shell polling wrappers such as `sleep &&`, `timeout`, `watch`, `head`, pipes, or loops. - UART read: use `hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1`; the compiler runs the configured serial-monitor CLI through `cmd.run`. @@ -34,4 +34,5 @@ The HWPOD spec is code-agent-side state. The default path is `.hwlab/hwpod-spec. - Do not put full `hwpod-spec` on `hwpod-node`. The node only accepts `hwpod-node-ops`. - Treat `cmd.run` as the full-capability host execution op. Do not request new hwpod-node ops for build, download, job status, UART, PowerShell/cmd passthrough, or tool maintenance; improve `hwpod-compiler-cli` / `hwpod-cli` combinations first. - Use workspace file operations for subject text edits. Do not use PowerShell/cmd heredocs for normal source edits unless debugging `cmd.run` itself. +- For multi-line edits, prefer `hwpod workspace apply-patch` with a Codex/UniDesk apply_patch v2 envelope from stdin or `--patch-content`. Put file paths in `*** Update File:` / `*** Add File:` / `*** Delete File:` headers. Do not pass raw unified diff (`---`, `+++`, `@@ -N`) as the whole patch; HWPOD deliberately rejects that format with a clear hint. - If HWPOD returns a blocker, report and fix that blocker in this path. diff --git a/tools/hwlab-cli/hwpod.test.ts b/tools/hwlab-cli/hwpod.test.ts index aa80b6f9..20ba1563 100644 --- a/tools/hwlab-cli/hwpod.test.ts +++ b/tools/hwlab-cli/hwpod.test.ts @@ -38,6 +38,24 @@ test("hwpod-cli workspace apply-patch passes stdin patch to hwpod-node plan", as } }); +test("hwpod-cli workspace apply-patch accepts --patch-content alias", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-test-patch-content-")); + const specPath = path.join(root, "hwpod-spec.yaml"); + const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n-old\n+new\n*** End Patch\n"; + try { + await writeFile(specPath, hwpodSpecText(), "utf8"); + const result = await runHwpodCli(["workspace", "apply-patch", "--spec", specPath, "--patch-content", patch, "--dry-run"], { + now: () => "2026-06-06T00:00:00.000Z" + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.intent, "workspace.apply-patch"); + assert.equal(result.payload.plan.ops[0].args.patch, patch); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function hwpodSpecText() { return "kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: F:\\\\Work\\\\HWLAB-CASE-F103\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n"; } diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 2851164d..34e4287f 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -340,6 +340,92 @@ test("hwpod-node preserves CRLF when applying workspace patches", async () => { } }); +test("hwpod-node apply-patch accepts UniDesk v2 compatible unified headers and unprefixed context", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-v2-")); + try { + await writeFile(path.join(root, "main.c"), [ + "#include ", + "int main(void) {", + " printf(\"OLD\\r\\n\");", + " return 0;", + "}", + "" + ].join("\n"), "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_patch_v2", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_patch", + op: "workspace.apply-patch", + args: { + workspacePath: root, + patch: [ + "*** Begin Patch", + "*** Update File: main.c", + "@@ -1,5 +1,6 @@", + "#include ", + "int main(void) {", + "- printf(\"OLD\\r\\n\");", + "+ printf(\"NEW\\r\\n\");", + "+ printf(\"TRACE\\r\\n\");", + " return 0;", + "}", + "*** End Patch", + "" + ].join("\n") + } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + const content = await readFile(path.join(root, "main.c"), "utf8"); + assert.equal(result.ok, true); + assert.equal(result.results[0].output.engine, "codex-apply-patch-v2-compatible"); + assert.match(result.results[0].output.hints.join("\n"), /unified-diff hunk header/u); + assert.match(result.results[0].output.hints.join("\n"), /unprefixed Update File context line/u); + assert.match(content, /printf\("NEW\\r\\n"\);/u); + assert.match(content, /printf\("TRACE\\r\\n"\);/u); + assert.doesNotMatch(content, /printf\("NEW\r\n"\);/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("hwpod-node apply-patch rejects raw unified diff with a precise format blocker", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-raw-diff-")); + try { + await writeFile(path.join(root, "main.c"), "int value = 1;\n", "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_patch_raw_diff", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_patch", + op: "workspace.apply-patch", + args: { + workspacePath: root, + patch: [ + "--- a/main.c", + "+++ b/main.c", + "@@ -1 +1 @@", + "-int value = 1;", + "+int value = 2;", + "" + ].join("\n") + } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(result.ok, false); + assert.equal(result.results[0].blocker.code, "unsupported_apply_patch_format"); + assert.equal(result.results[0].blocker.details.engine, "codex-apply-patch-v2-compatible"); + assert.equal(result.results[0].blocker.details.format, "unified-diff"); + assert.match(result.results[0].blocker.summary, /Codex\/UniDesk apply_patch v2 envelope/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node returns apply-patch diagnostics when context is missing", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-diagnostics-")); try { diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 896b3b7c..3be4fb18 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -43,7 +43,7 @@ async function readCliStdinForCommand(argv: string[]): Promise --content --expected-sha ", "hwpod workspace replace --path --find --replace --expected-sha ", "hwpod workspace insert-after --path --anchor --line --expected-sha ", - "cat patch.txt | hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason " + "cat patch.txt | hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason ", + "hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --patch-content ''" ], aliases: { read: "workspace.cat", file: "--path", - remotePath: "--path" + remotePath: "--path", + patchContent: "--patch" }, + applyPatchEngine: "codex-apply-patch-v2-compatible", + applyPatchBoundary: "workspace apply-patch accepts a Codex/UniDesk v2 envelope. Put target paths in *** Update/Add/Delete File headers; --remote-path is only a path alias for file-oriented workspace commands, not a substitute for envelope headers.", boundary: "Workspace file operations are the standard editing path; do not use shell quoting for subject text edits unless debugging cmd.run itself." }); } @@ -421,7 +425,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } }; if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } }; if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } }; - if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } }; + if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } }; if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; if (subcommand === "insert-after") return { intent: "workspace.insert-after", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), anchor: requiredRawString(parsed.anchor ?? parsed.marker ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.content ?? parsed.insert ?? parsed.text ?? parsed._[4], "line"), expectedSha: text(parsed.expectedSha), allowMultiple: parsed.allowMultiple === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 671f0f86..e824a660 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -386,10 +386,11 @@ async function workspaceRg(args: any) { } async function workspaceApplyPatch(args: any) { - const patch = text(args.patch) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : ""); - if (!patch) throw cliError("patch_required", "patch or patchBase64 is required"); + const patch = text(args.patch) || text(args.patchContent) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : ""); + if (!patch) throw cliError("patch_required", "patch, patchContent, or patchBase64 is required", { acceptedArgs: ["patch", "patchContent", "patchBase64"], engine: APPLY_PATCH_ENGINE }); const cwd = workspaceRoot(args); - return { cwd, changes: await applyPatchEnvelope(cwd, patch) }; + const result = await applyPatchEnvelope(cwd, patch); + return { cwd, ...result }; } async function workspaceWrite(args: any) { @@ -514,78 +515,481 @@ function normalizeCommandRun(value: any, index: number) { return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs }; } +const APPLY_PATCH_ENGINE = "codex-apply-patch-v2-compatible"; +const APPLY_PATCH_BEGIN_MARKER = "*** Begin Patch"; +const APPLY_PATCH_END_MARKER = "*** End Patch"; +const APPLY_PATCH_ENVIRONMENT_MARKER = "*** Environment ID: "; +const APPLY_PATCH_ADD_FILE_MARKER = "*** Add File: "; +const APPLY_PATCH_DELETE_FILE_MARKER = "*** Delete File: "; +const APPLY_PATCH_UPDATE_FILE_MARKER = "*** Update File: "; +const APPLY_PATCH_MOVE_TO_MARKER = "*** Move to: "; +const APPLY_PATCH_EOF_MARKER = "*** End of File"; + +type HwpodPatchHunk = + | { kind: "add"; path: string; content: string } + | { kind: "delete"; path: string } + | { kind: "update"; path: string; movePath: string | null; chunks: HwpodPatchChunk[] }; + +type HwpodPatchChunk = { + changeContext: string | null; + sourceStartLine: number | null; + oldLines: string[]; + newLines: string[]; + contextLinePairs: Array<{ oldIndex: number; newIndex: number }>; + contextLineCount: number; + addedLineCount: number; + deletedLineCount: number; + isEndOfFile: boolean; +}; + +type HwpodPatchReplacement = [start: number, oldLength: number, newLines: string[]]; + async function applyPatchEnvelope(root: string, patch: string) { - const lines = patch.replace(/\r\n?/gu, "\n").split("\n"); - while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); - if (lines[0] !== "*** Begin Patch" || lines[lines.length - 1] !== "*** End Patch") { - throw cliError("invalid_apply_patch_envelope", "patch must start with *** Begin Patch and end with *** End Patch"); + const parsed = parseHwpodApplyPatchV2(patch); + const changes: any[] = []; + const outcomes: any[] = []; + for (let index = 0; index < parsed.hunks.length; index += 1) { + const hunk = parsed.hunks[index]; + try { + const change = await applyParsedPatchHunk(root, hunk); + changes.push(change); + outcomes.push({ hunk: index + 1, action: change.action, path: change.path, targetPath: change.targetPath ?? undefined, status: "applied", change }); + } catch (error: any) { + const failed = { + hunk: index + 1, + action: hunk.kind, + path: hunk.path, + targetPath: hunk.kind === "update" ? hunk.movePath ?? undefined : undefined, + status: "failed", + error: { code: error?.code || "apply_patch_v2_failed", message: error?.message || String(error), details: error?.details } + }; + outcomes.push(failed); + throw cliError(error?.code || "apply_patch_v2_failed", error?.message || String(error), { ...(error?.details ?? {}), engine: APPLY_PATCH_ENGINE, partialChanges: changes, outcomes, failed, cause: error?.details }); + } } - const changes = []; - for (let index = 1; index < lines.length - 1;) { - const header = lines[index]; - if (header.startsWith("*** Add File: ")) { - const relativePath = header.slice("*** Add File: ".length).trim(); - const filePath = resolvePatchFile(root, relativePath); - const addLines = []; - index += 1; - while (index < lines.length - 1 && !lines[index].startsWith("*** ")) { - if (!lines[index].startsWith("+")) throw cliError("invalid_add_file_hunk", `add file lines must start with + for ${relativePath}`); - addLines.push(lines[index].slice(1)); - index += 1; - } - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, `${addLines.join("\n")}\n`, "utf8"); - changes.push({ action: "add", path: relativePath, lines: addLines.length }); - continue; - } - if (header.startsWith("*** Delete File: ")) { - const relativePath = header.slice("*** Delete File: ".length).trim(); - await rm(resolvePatchFile(root, relativePath), { force: false }); - changes.push({ action: "delete", path: relativePath }); - index += 1; - continue; - } - if (header.startsWith("*** Update File: ")) { - const relativePath = header.slice("*** Update File: ".length).trim(); - const filePath = resolvePatchFile(root, relativePath); - const hunks = []; - index += 1; - while (index < lines.length - 1 && !lines[index].startsWith("*** ")) { - if (!lines[index].startsWith("@@")) throw cliError("invalid_update_hunk", `update hunk must start with @@ for ${relativePath}`); - index += 1; - const hunkLines = []; - while (index < lines.length - 1 && !lines[index].startsWith("@@") && !lines[index].startsWith("*** ")) { - const prefix = lines[index][0]; - if (![" ", "-", "+"].includes(prefix)) throw cliError("invalid_update_hunk_line", `hunk lines must start with space, -, or + for ${relativePath}`); - hunkLines.push(lines[index]); - index += 1; - } - hunks.push(hunkLines); - } - const applied = await applyUpdateHunks(filePath, hunks, relativePath); - changes.push({ action: "update", path: relativePath, hunks: hunks.length, replacements: applied }); - continue; - } - throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${header}`); - } - return changes; + if (changes.length === 0) throw cliError("apply_patch_no_changes", "No files were modified.", { engine: APPLY_PATCH_ENGINE }); + return { engine: APPLY_PATCH_ENGINE, changes, hints: parsed.hints, outcomes }; } -async function applyUpdateHunks(filePath: string, hunks: string[][], relativePath: string) { - const before = await readTextFileState(filePath); - let fileLines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline); - let searchStart = 0; - for (const hunk of hunks) { - const oldLines = hunk.filter((line) => line[0] !== "+").map((line) => line.slice(1)); - const newLines = hunk.filter((line) => line[0] !== "-").map((line) => line.slice(1)); - const at = findLineSequence(fileLines, oldLines, searchStart); - if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, diagnosticDetails(before, oldLines.join("\n"), relativePath)); - fileLines = [...fileLines.slice(0, at), ...newLines, ...fileLines.slice(at + oldLines.length)]; - searchStart = at + newLines.length; +async function applyParsedPatchHunk(root: string, hunk: HwpodPatchHunk) { + if (hunk.kind === "add") { + const filePath = resolvePatchFile(root, hunk.path); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, hunk.content, "utf8"); + return { action: "add", path: hunk.path, lines: splitPatchContentLines(hunk.content).length }; } - const afterNormalized = `${fileLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`; - await writeFile(filePath, normalizeContentLineEndings(afterNormalized, before.lineEnding, false), "utf8"); - return hunks.length; + if (hunk.kind === "delete") { + await rm(resolvePatchFile(root, hunk.path), { force: false }); + return { action: "delete", path: hunk.path }; + } + const filePath = resolvePatchFile(root, hunk.path); + const update = await derivePatchUpdate(filePath, hunk.chunks, hunk.path); + if (hunk.movePath !== null && hunk.movePath !== hunk.path) { + const targetPath = resolvePatchFile(root, hunk.movePath); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, update.afterContent, "utf8"); + await rm(filePath, { force: false }); + return { action: "move", path: hunk.path, targetPath: hunk.movePath, hunks: hunk.chunks.length, replacements: update.replacements }; + } + await writeFile(filePath, update.afterContent, "utf8"); + return { action: "update", path: hunk.path, hunks: hunk.chunks.length, replacements: update.replacements }; +} + +function parseHwpodApplyPatchV2(patch: string): { hunks: HwpodPatchHunk[]; hints: string[] } { + const patchText = stripLenientPatchHeredoc(patch).trim(); + const lines = patchText.length === 0 ? [] : patchText.split(/\r?\n/u); + const first = lines[0]?.trim(); + const last = lines[lines.length - 1]?.trim(); + if (first !== APPLY_PATCH_BEGIN_MARKER || last !== APPLY_PATCH_END_MARKER) throwInvalidApplyPatchEnvelope(patchText, first, last); + + const hunks: HwpodPatchHunk[] = []; + const hints: string[] = []; + let index = 1; + if ((lines[index] ?? "").trimStart().startsWith(APPLY_PATCH_ENVIRONMENT_MARKER)) { + const environmentId = (lines[index] ?? "").slice(APPLY_PATCH_ENVIRONMENT_MARKER.length).trim(); + if (!environmentId) throw cliError("invalid_apply_patch_environment", "apply_patch environment_id cannot be empty", { engine: APPLY_PATCH_ENGINE, line: index + 1 }); + index += 1; + } + while (index < lines.length - 1) { + const line = (lines[index] ?? "").trim(); + if (!line) { + index += 1; + continue; + } + if (line === APPLY_PATCH_BEGIN_MARKER || line === APPLY_PATCH_END_MARKER) { + pushUniquePatchHint(hints, `ignored nested ${line}`, `apply-patch hint: ignored nested ${line} marker on line ${index + 1}; keep one outer envelope around all hunks.`); + index += 1; + continue; + } + if (line.startsWith(APPLY_PATCH_ADD_FILE_MARKER)) { + const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_ADD_FILE_MARKER.length), index + 1); + index += 1; + const added: string[] = []; + while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) { + const addLine = lines[index] ?? ""; + if (addLine.startsWith("+")) { + added.push(addLine.length > 1 && addLine.slice(1).trim().length === 0 ? "" : addLine.slice(1)); + } else if (addLine.trimStart().startsWith("@@")) { + hints.push(`apply-patch hint: accepted @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`); + } else if (addLine.trim().length === 0) { + hints.push(`apply-patch hint: accepted a bare blank line inside Add File ${filePath} on line ${index + 1}; prefer a line containing only +.`); + added.push(""); + } else { + hints.push(`apply-patch hint: accepted unprefixed Add File content in ${filePath} on line ${index + 1}; prefix new-file content lines with +.`); + added.push(addLine); + } + index += 1; + } + hunks.push({ kind: "add", path: filePath, content: joinPatchLinesWithFinalNewline(added) }); + continue; + } + if (line.startsWith(APPLY_PATCH_DELETE_FILE_MARKER)) { + const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_DELETE_FILE_MARKER.length), index + 1); + index += 1; + const extraLines: number[] = []; + while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) { + if ((lines[index] ?? "").trim().length > 0) extraLines.push(index + 1); + index += 1; + } + if (extraLines.length > 0) hints.push(`apply-patch hint: ignored extra hunk/body lines after Delete File ${filePath} on line ${extraLines[0]}; Delete File only needs the header.`); + hunks.push({ kind: "delete", path: filePath }); + continue; + } + if (line.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER)) { + const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_UPDATE_FILE_MARKER.length), index + 1); + index += 1; + let movePath: string | null = null; + if ((lines[index] ?? "").startsWith(APPLY_PATCH_MOVE_TO_MARKER)) { + movePath = validateApplyPatchPath((lines[index] ?? "").slice(APPLY_PATCH_MOVE_TO_MARKER.length), index + 1); + index += 1; + } + const chunks: HwpodPatchChunk[] = []; + while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) { + if ((lines[index] ?? "").trim().length === 0) { + index += 1; + continue; + } + const parsed = parseApplyPatchUpdateChunk(lines, index, chunks.length === 0, filePath, hints); + chunks.push(parsed.chunk); + index = parsed.nextIndex; + } + if (chunks.length === 0) throw cliError("invalid_update_hunk", "update file hunk is empty", { engine: APPLY_PATCH_ENGINE, line: index + 1, path: filePath }); + hunks.push({ kind: "update", path: filePath, movePath, chunks }); + continue; + } + throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${line}`, { engine: APPLY_PATCH_ENGINE, line: index + 1, text: line }); + } + return { hunks, hints }; +} + +function parseApplyPatchUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]) { + let index = startIndex; + let changeContext: string | null = null; + let sourceStartLine: number | null = null; + const first = lines[index] ?? ""; + if (first === "@@") { + index += 1; + } else { + const unifiedHeader = parseApplyPatchUnifiedHunkHeader(first); + if (unifiedHeader !== null) { + sourceStartLine = unifiedHeader.oldStart; + hints.push(`apply-patch hint: accepted unified-diff hunk header in ${filePath} on line ${startIndex + 1}; canonical apply_patch uses @@ or @@ context without line ranges.`); + index += 1; + } else if (first.startsWith("@@ ")) { + changeContext = first.slice("@@ ".length); + index += 1; + } else if (!allowMissingContext) { + throw cliError("invalid_update_hunk", "expected update chunk to start with @@ context marker", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1, text: first }); + } + } + + const oldLines: string[] = []; + const newLines: string[] = []; + const contextLinePairs: Array<{ oldIndex: number; newIndex: number }> = []; + let parsed = 0; + let isEndOfFile = false; + let contextLineCount = 0; + let addedLineCount = 0; + let deletedLineCount = 0; + function pushContextLine(value: string) { + contextLinePairs.push({ oldIndex: oldLines.length, newIndex: newLines.length }); + oldLines.push(value); + newLines.push(value); + contextLineCount += 1; + } + while (index < lines.length - 1) { + const line = lines[index] ?? ""; + if (isApplyPatchFileHeader(line)) break; + if (parsed > 0 && isApplyPatchUpdateChunkHeader(line)) break; + if (line === APPLY_PATCH_EOF_MARKER) { + if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: index + 1 }); + isEndOfFile = true; + index += 1; + break; + } + const marker = line[0] ?? ""; + if (marker === " ") { + pushContextLine(line.slice(1)); + } else if (marker === "+") { + newLines.push(line.slice(1)); + addedLineCount += 1; + } else if (marker === "-") { + oldLines.push(line.slice(1)); + deletedLineCount += 1; + } else if (line.length === 0) { + pushContextLine(""); + } else { + pushUniquePatchHint(hints, `unprefixed context ${filePath}`, `apply-patch hint: accepted unprefixed Update File context line in ${filePath} on line ${index + 1}; prefix context lines with one extra space in addition to source indentation.`); + pushContextLine(line); + } + parsed += 1; + index += 1; + } + if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1 }); + return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index }; +} + +async function derivePatchUpdate(filePath: string, chunks: HwpodPatchChunk[], relativePath: string) { + const before = await readTextFileState(filePath); + const originalLines = splitPatchContentLines(before.normalizedContent); + const replacements = computePatchReplacements(relativePath, before, originalLines, chunks); + const afterNormalized = joinPatchLinesWithFinalNewline(applyPatchReplacements(originalLines, replacements)); + const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false); + return { before, afterContent, replacements: replacements.length }; +} + +function computePatchReplacements(relativePath: string, before: any, originalLines: string[], chunks: HwpodPatchChunk[]): HwpodPatchReplacement[] { + const replacements: HwpodPatchReplacement[] = []; + let lineIndex = 0; + for (const [chunkIndex, chunk] of chunks.entries()) { + if (chunk.changeContext !== null) { + const foundContext = seekPatchSequence(originalLines, [chunk.changeContext], lineIndex, false); + if (foundContext === null) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, { ...diagnosticDetails(before, chunk.changeContext, relativePath), engine: APPLY_PATCH_ENGINE, chunk: chunkIndex + 1, context: chunk.changeContext }); + lineIndex = foundContext + 1; + } + if (chunk.oldLines.length === 0) { + replacements.push([originalLines.length, 0, chunk.newLines]); + continue; + } + let pattern = chunk.oldLines; + const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1); + let found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile); + let newLines = chunk.newLines; + if (found === null && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1); + newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines; + found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile); + } + if (found === null) { + const expected = chunk.oldLines.join("\n"); + throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, { + ...diagnosticDetails(before, expected, relativePath), + engine: APPLY_PATCH_ENGINE, + chunk: chunkIndex + 1, + expected, + diagnostics: expectedPatchLineDiagnostics(originalLines, chunk, preferredStart) + }); + } + replacements.push([found, pattern.length, preservePatchMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]); + lineIndex = found + pattern.length; + } + assertPatchReplacements(relativePath, replacements, originalLines.length); + return replacements; +} + +function throwInvalidApplyPatchEnvelope(patchText: string, first?: string, last?: string): never { + const format = detectPatchTextFormat(patchText); + if (format === "unified-diff") { + throw cliError("unsupported_apply_patch_format", "hwpod workspace.apply-patch uses the Codex/UniDesk apply_patch v2 envelope; raw unified diff is not accepted", { + engine: APPLY_PATCH_ENGINE, + format, + expectedFirstLine: APPLY_PATCH_BEGIN_MARKER, + expectedLastLine: APPLY_PATCH_END_MARKER, + hint: "Wrap edits in *** Begin Patch / *** Update File: / @@ / *** End Patch, or use workspace.write for a whole-file rewrite." + }); + } + throw cliError("invalid_apply_patch_envelope", `patch must start with ${APPLY_PATCH_BEGIN_MARKER} and end with ${APPLY_PATCH_END_MARKER}`, { engine: APPLY_PATCH_ENGINE, firstLine: first ?? null, lastLine: last ?? null }); +} + +function detectPatchTextFormat(patchText: string) { + const trimmed = patchText.trimStart(); + if (trimmed.startsWith("diff --git") || trimmed.startsWith("--- ") || trimmed.startsWith("@@ ")) return "unified-diff"; + return "unknown"; +} + +function stripLenientPatchHeredoc(textValue: string) { + const trimmed = textValue.trim(); + const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u); + const first = lines[0] ?? ""; + const last = lines[lines.length - 1] ?? ""; + if ((first === "<= 4) return lines.slice(1, -1).join("\n"); + return textValue; +} + +function validateApplyPatchPath(value: string, line: number) { + const filePath = value.trim(); + if (!filePath || path.isAbsolute(filePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { engine: APPLY_PATCH_ENGINE, line, path: filePath }); + return filePath; +} + +function isApplyPatchFileHeader(line: string) { + const trimmed = line.trim(); + return trimmed.startsWith(APPLY_PATCH_ADD_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_DELETE_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER) || trimmed === APPLY_PATCH_BEGIN_MARKER || trimmed === APPLY_PATCH_END_MARKER; +} + +function isApplyPatchUpdateChunkHeader(line: string) { + return line === "@@" || line.startsWith("@@ ") || parseApplyPatchUnifiedHunkHeader(line) !== null; +} + +function parseApplyPatchUnifiedHunkHeader(line: string) { + const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line); + if (match === null) return null; + const oldStart = Number(match[1] ?? "0"); + return Number.isSafeInteger(oldStart) && oldStart >= 0 ? { oldStart } : null; +} + +function splitPatchContentLines(content: string) { + const lines = content.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +function joinPatchLinesWithFinalNewline(lines: string[]) { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} + +function applyPatchReplacements(lines: string[], replacements: HwpodPatchReplacement[]) { + const result = [...lines]; + for (const [start, oldLength, newSegment] of [...replacements].reverse()) result.splice(start, oldLength, ...newSegment); + return result; +} + +function assertPatchReplacements(relativePath: string, replacements: HwpodPatchReplacement[], lineCount: number) { + const sorted = [...replacements].sort((left, right) => left[0] - right[0]); + let previousEnd = 0; + for (const [start, oldLength] of sorted) { + if (start < 0 || oldLength < 0 || start + oldLength > lineCount) throw cliError("apply_patch_replacement_out_of_bounds", "computed replacement is outside file bounds", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, oldLength, lineCount }); + if (start < previousEnd) throw cliError("apply_patch_replacement_overlap", "computed replacements overlap", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, previousEnd }); + previousEnd = Math.max(previousEnd, start + oldLength); + } +} + +function seekPatchSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean) { + const preferred = seekPatchSequence(lines, pattern, preferredStart, eof); + if (preferred !== null || preferredStart === fallbackStart) return preferred; + return seekPatchSequence(lines, pattern, fallbackStart, eof); +} + +function seekPatchSequence(lines: string[], pattern: string[], start: number, eof: boolean) { + if (pattern.length === 0) return start; + if (pattern.length > lines.length) return null; + const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : Math.max(0, start); + const attempts: Array<(value: string) => string> = [ + (value) => value, + (value) => value.trimEnd(), + (value) => value.trim(), + normalizePatchLine + ]; + for (const normalize of attempts) { + for (let index = searchStart; index <= lines.length - pattern.length; index += 1) { + let ok = true; + for (let offset = 0; offset < pattern.length; offset += 1) { + if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) { + ok = false; + break; + } + } + if (ok) return index; + } + } + return null; +} + +function preservePatchMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: HwpodPatchChunk["contextLinePairs"], matchedOldLength: number) { + if (contextLinePairs.length === 0) return newLines; + const result = [...newLines]; + for (const pair of contextLinePairs) { + if (pair.oldIndex >= matchedOldLength || pair.newIndex >= result.length) continue; + const originalLine = originalLines[found + pair.oldIndex]; + if (originalLine !== undefined) result[pair.newIndex] = originalLine; + } + return result; +} + +function expectedPatchLineDiagnostics(originalLines: string[], chunk: HwpodPatchChunk, preferredStart: number) { + const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? ""; + const firstExpectedLineCandidates = firstExpectedLine ? patchCandidateLineNumbers(originalLines, firstExpectedLine, 8) : []; + const prefix = bestPatchPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart); + const missingAddedPrefixes = likelyPatchMissingAddedPrefixes(chunk, prefix.matchedLines); + return { + firstExpectedLine, + firstExpectedLineCandidates, + firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && patchCandidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length, + bestPrefixMatchedLines: prefix.matchedLines, + bestPrefixStartLine: prefix.startLine, + likelyMissingAddedPrefixes: missingAddedPrefixes, + likelyStaleOrOversizedContext: !missingAddedPrefixes && likelyPatchStaleOrOversizedContext(chunk, prefix.matchedLines) + }; +} + +function patchCandidateLineNumbers(lines: string[], expectedLine: string, limit: number) { + const result: number[] = []; + for (let index = 0; index < lines.length; index += 1) { + if (patchLineEquivalent(lines[index] ?? "", expectedLine)) { + result.push(index + 1); + if (result.length >= limit) break; + } + } + return result; +} + +function bestPatchPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: number) { + let best = { startLine: null as number | null, matchedLines: 0 }; + if (expectedLines.length === 0) return best; + for (let index = Math.max(0, preferredStart); index < lines.length; index += 1) { + if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue; + let matched = 0; + while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1; + if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched }; + if (matched === expectedLines.length) break; + } + if (best.matchedLines > 0 || preferredStart <= 0) return best; + for (let index = 0; index < Math.min(preferredStart, lines.length); index += 1) { + if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue; + let matched = 0; + while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1; + if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched }; + } + return best; +} + +function likelyPatchMissingAddedPrefixes(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) { + if (chunk.deletedLineCount > 0) return false; + if (chunk.oldLines.length < 8) return false; + if (chunk.addedLineCount > 2) return false; + if (chunk.contextLineCount < 8) return false; + return bestPrefixMatchedLines > 0 && bestPrefixMatchedLines < chunk.oldLines.length; +} + +function likelyPatchStaleOrOversizedContext(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) { + if (chunk.oldLines.length < 4) return false; + if (bestPrefixMatchedLines < 2 || bestPrefixMatchedLines >= chunk.oldLines.length) return false; + return chunk.addedLineCount > 0 || chunk.deletedLineCount > 0 || chunk.contextLineCount >= 4; +} + +function patchLineEquivalent(left: string, right: string) { + return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizePatchLine(left) === normalizePatchLine(right); +} + +function normalizePatchLine(value: string) { + return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-") + .replace(/[\u2018\u2019\u201A\u201B]/gu, "'") + .replace(/[\u201C\u201D\u201E\u201F]/gu, "\"") + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " "); +} + +function pushUniquePatchHint(hints: string[], prefix: string, hint: string) { + if (!hints.some((existing) => existing.includes(prefix))) hints.push(hint); } async function readTextFileState(filePath: string) {