Skip to content

Fix Windows host resolution and batch spawning (#716) - #720

Merged
MongLong0214 merged 6 commits into
MongLong0214:mainfrom
plutoparty1:fix-716-windows-exec
Aug 17, 2026
Merged

Fix Windows host resolution and batch spawning (#716)#720
MongLong0214 merged 6 commits into
MongLong0214:mainfrom
plutoparty1:fix-716-windows-exec

Conversation

@plutoparty1

@plutoparty1 plutoparty1 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #716. This addresses cause 2 and the independent-review blockers without enabling shell: true:

  • resolve host commands once through the exact file used for both detection and execution;
  • honor PATHEXT on Windows and require X_OK on POSIX, so a non-executable shadow cannot mask a later real CLI;
  • invoke .cmd/.bat through explicit cmd.exe argv whose /c text contains only random one-use environment references, never wrapper or user paths;
  • preserve literal %, &, |, ^, !, spaces, and trailing backslashes, including a final --verify token after a data-root ending in \\;
  • keep host failures honest and carry the first spawn/stderr line into the host detail.

The branch includes merged #717, #718, and the 1.1.0 release from #721. The prior owner-built dist/ remains untouched and the latest main manifest/version metadata wins the merge. This Windows follow-up changes src/ and test/ only, so the Linux canonical builder must refresh dist/ and installer/canonical-artifact.json again before merge.

Real Windows evidence

Environment: Windows 10.0.19045.0, PowerShell 5.1.19041.6456, Node v24.12.0.

Machine scope actually observed:

  • Codex CLI installed on PATH: codex.exe, codex.cmd, codex.ps1; codex-cli 0.147.0.
  • Gemini, Cursor, and Hermes detected through their real config locations; none had a CLI on PATH.
  • Claude config %USERPROFILE%\\.claude.json exists, but no claude executable exists. Current enumeration is executable-only, so it remains notDetected by design pending a product decision.
  • Windsurf and OpenCode were genuinely absent.

Before: owner observation on v1.0.2

{"schema":"commitlore_installer_hosts.v1","runtimeIdentity":{"version":"1.0.2","entrypoint":"C:\\Users\\u\\AppData\\Local\\commitlore\\v1.0.2\\dist\\commitlore.mjs","packageRoot":"C:\\Users\\u\\AppData\\Local\\commitlore\\v1.0.2","indexSchemaVersion":4},"ok":false,"hosts":[{"host":"codex","requested":true,"outcome":"failed","healthy":false,"detail":"codex mcp add failed"},{"host":"gemini-cli","requested":true,"outcome":"failed","healthy":false,"detail":"atomic config write failed: ENOENT: no such file or directory, open 'C:\\Users\\u\\.gemini\\.C:\\Users\\u\\.gemini\\settings.json.commitlore-41756-3e1a1692-8aec-445c-be59-5e90a8be2cd0.tmp'"},{"host":"cursor","requested":true,"outcome":"failed","healthy":false,"detail":"config is not parseable JSON: Unexpected end of JSON input"},{"host":"hermes","requested":true,"outcome":"failed","healthy":false,"detail":"Hermes setup failed"}],"notDetected":["windsurf","opencode","claude-code"]}

After: current source, correct package layout

Command:

& $dest installer-hosts --wrapper $dest --data-root $dataRoot --home $env:USERPROFILE --json

Exit: 1, solely because the real Cursor target is still an empty, invalid JSON file.

{"schema":"commitlore_installer_hosts.v1","runtimeIdentity":{"version":"1.0.2","entrypoint":"C:\\Users\\u\\AppData\\Local\\Temp\\commitlore-716-e2e-final\\dist\\commitlore.mjs","packageRoot":"C:\\Users\\u\\AppData\\Local\\Temp\\commitlore-716-e2e-final","indexSchemaVersion":4},"ok":false,"hosts":[{"host":"codex","requested":true,"outcome":"custom-preserved","healthy":true,"detail":"healthy custom registration preserved; plugin installed"},{"host":"gemini-cli","requested":true,"outcome":"custom-preserved","healthy":true,"detail":"healthy custom registration preserved"},{"host":"cursor","requested":true,"outcome":"failed","healthy":false,"detail":"C:\\Users\\u\\.cursor\\mcp.json is not parseable JSON: Unexpected end of JSON input"},{"host":"hermes","requested":true,"outcome":"installed","healthy":true,"detail":"Hermes setup verified"}],"notDetected":["windsurf","opencode","claude-code"]}

This is intentionally not presented as a seven-host green run. Cursor stays failed; Windsurf, OpenCode, and Claude Code stay in notDetected.

Direct disk readback after the final run

host installer said config on disk after recorded command runs?
codex custom-preserved, healthy %USERPROFILE%\\.codex\\config.toml: commitlore.cmd, args = ["mcp"] MCP Initialize advertised CommitLore read/capture tools; probe exit 0
gemini-cli custom-preserved, healthy %USERPROFILE%\\.gemini\\settings.json: same registered commitlore.cmd, ["mcp"] MCP Initialize advertised CommitLore read/capture tools; probe exit 0
cursor failed %USERPROFILE%\\.cursor\\mcp.json remained 0 bytes, SHA-256 E3B0...B855; no entry not runnable: no entry was written
hermes installed, healthy %USERPROFILE%\\.hermes\\config.yaml: current staged commitlore.cmd, [mcp], enabled, and shipped skills directory MCP Initialize advertised CommitLore read/capture tools; probe exit 0
claude-code notDetected %USERPROFILE%\\.claude.json remained byte-identical; no entry not checked: executable absent

Codex, Gemini, Cursor, Hermes, and Claude config SHA-256 values were identical immediately before and after this final idempotent run. Hermes had already been written by the preceding correct-layout run; this final run verified it without another mutation.

Independent-review red ? green evidence

  • Trailing backslash: at cca98cc, expected ["...\\data root\\", "--verify"]; the child actually received one argument: "...\\data root\\\" --verify". Current code captures the two exact arguments separately.
  • Percent paths: direct cmd.exe /c proved naive %% is not exact: %%NAME%% became %EXPANDED%. One-use environment expansion is one-pass; the real batch child received literal 100% Ready and %PATH%.
  • Metacharacters: the real Windows child received A&B, A|B, A^B, !PATH!, and a space-bearing path exactly.
  • Failure visibility: a Hermes batch wrapper exiting 23 with missing Hermes skills sentinel on stderr now produces Hermes setup failed: missing Hermes skills sentinel.
  • POSIX executable search: a Linux-only regression test creates mode-0644 claude before mode-0755 claude; the resolver must select the latter.
  • Test honesty: the PATHEXT test now creates only codex.CMD; its name no longer claims a condition the fixture did not establish.

Hermes and Gemini findings

The earlier Hermes failure was not another missed spawn site. The temporary source stage omitted the shipped hermes/skills directory. With that directory absent, the same current bundle exited 2 with:

could not find the CommitLore Hermes skill bundle at C:\Users\u\AppData\Local\Temp\commitlore-716-e2e-final\hermes\skills

Adding only the shipped directory made the identical command exit 0 and write the verified Hermes entry. The corrected package layout reports Hermes installed, healthy:true above.

Gemini's initial 15-second MCP timeout did not reproduce in four consecutive corrected-layout enumerations: 4/4 were healthy. That establishes it as a one-off observation on this machine, not an explained or reproducible failure.

Deliberately unchanged

  • Claude Code detection remains executable-only; no config fallback was added on a guess.
  • Cursor remains failed against its actual 0-byte %USERPROFILE%\\.cursor\\mcp.json.
  • The pre-existing DEP0190 warning from src/core/mcp-probe.ts remains outside this PR.
  • Cause 1 is unchanged.

Verification

  • npx tsc --noEmit clean.
  • Pre-fix cca98cc Windows argv test: 1 failed, showing --verify merged into the trailing-backslash path.
  • Current Windows command/Hermes boundary: 7 passed, one POSIX-only case skipped on Windows.
  • Source-level installer/Hermes suites: 32 passed, one POSIX-only case skipped on Windows.
  • Spawned installer suite against a fresh current-source bundle: 12 passed.
  • validate --range origin/main..HEAD --json: examined:5, violations:[].
  • Real final enumeration JSON, direct config readback, and both recorded MCP commands probed above.

@MongLong0214 MongLong0214 mentioned this pull request Aug 17, 2026
@MongLong0214

Copy link
Copy Markdown
Owner

The evidence here is the kind this issue asked for and did not have: a real machine, agents actually installed, before and after, disk readback, and an MCP Initialize response from the command that was written. ok: false is preserved and the hosts that failed are still reported failed — it is not sold as a seven-host green run. Thank you for that; the honesty about what did not work is what makes the rest usable.

Three things to settle before this merges. The first two are mechanical; the third is a finding.

1. Fixes #716 cause 2 closes all of #716

GitHub's parser reads Fixes #716 and stops. cause 2 is not part of the reference, so merging this closes the whole issue. That is the shape that wrongly closed #549 this morning.

2. dist/ and installer/canonical-artifact.json still need layering

Correctly left out — a Windows machine cannot produce the docker linux/amd64 node:24-bookworm build that CI compares bytes against. Someone with Docker has to add the canonical rebuild before this can go green. I can do that. Note that #719 records the conflict this creates with any other open PR touching source.

3. Hermes is still failing, and this issue predicted it would be fixed

From the after-runs, both of them:

{"host":"hermes","requested":true,"outcome":"failed","healthy":false,"detail":"Hermes setup failed"}

#716's body attributes it to cause 2: "codex mcp add failed and Hermes setup failed are both this shape." Codex now works. Hermes does not. So one of two things is true, and the difference matters more than the fix does:

The second is checkable from the source. The first needs the machine. Either way, #716 is not finished, which is the concrete reason the closing keyword above has to change rather than a procedural one.

claude-code is a smaller version of the same question: still notDetected with %USERPROFILE%\.claude.json present. If its detection is executable-only while the JSON hosts fall back to a config directory, that asymmetry is a decision nobody has recorded.

On the cursor line

%USERPROFILE%\.cursor\mcp.json exists and is exactly 0 bytes

That settles it, and it settles it against my own suspicion. #716 recorded the cursor outcome as confirmed and its reason as unestablished, because Unexpected end of JSON input names a parse without naming the file, and an independent check of that machine had found valid JSON elsewhere. Both were true: the installer read the file it says it reads, and that file was empty. The installer's reason was right and the doubt was mine.

#718 makes that answerable without a byte count next time — the same failure now prints <path> is not parseable JSON: …. Worth re-running once this lands on top of it, so the receipt says it rather than a comment thread.

@MongLong0214

Copy link
Copy Markdown
Owner

Next, while you still have the machine

The machine is the scarce part. Everything below needs it; nothing below can be settled from a Linux runner, and that is the whole reason this work is yours.

Work on this branch. Update it from main first — #718 landed there after you branched, and it changes what your re-run will print:

git fetch origin && git merge origin/main

1. Hermes — the finding that matters

Both of your after-runs still show:

{"host":"hermes","requested":true,"outcome":"failed","healthy":false,"detail":"Hermes setup failed"}

#716 attributes that to the same cause you just fixed: "codex mcp add failed and Hermes setup failed are both this shape." Codex now works. Hermes does not. So one of these is true:

Check the first from the source — it is a five-minute read. If that is it, fix it here.

If it is the second, stop and report rather than expanding this PR. stdio: 'ignore' means the step currently throws away whatever Hermes said; the first useful move is to capture that output and paste it, not to guess. A named third cause with its stderr is worth more than a speculative fix.

2. claude-code detection

Still notDetected with %USERPROFILE%\.claude.json present. Every JSON host falls back to a config directory when the executable is absent; claude-code appears not to. That asymmetry may be deliberate, but nobody has written down which it is.

Say what you observe: does the enumeration check for an executable only, or is there a config fallback that did not fire? Do not change the behaviour on a guess — record the question with the evidence and it becomes decidable.

3. Re-run so the receipt says it

After merging main, run the enumeration again. #718 makes every host failure lead with the file it read, so the cursor line should now print:

<path> is not parseable JSON: Unexpected end of JSON input

instead of the unattributable form. Your 0-byte finding is what closed that question; this makes the output say it without a comment thread. Paste the new JSON.

Also worth one line: your first run had gemini-cli fail with MCP initialize timed out after 15000ms and the rerun had it owned, healthy. That is a probe that passed on the second attempt. Say whether it reproduced — a timeout that appears once and never again is a different problem from one that appears half the time, and only the machine can tell them apart.

4. Your PR body closes the wrong thing

Fixes #716 cause 2

GitHub's parser reads Fixes #716 and stops; cause 2 is not part of the reference, so merging this closes the whole issue. Given Hermes above, #716 is not finished. Please change it to a form that does not close — Addresses cause 2 of #716 reads the same to a human and nothing to the parser.

Not yours

  • dist/ and installer/canonical-artifact.json. Leave them out exactly as you have. The canonical docker linux/amd64 rebuild will be layered on before this merges — a Windows machine cannot produce it.
  • Anything outside #716. The DEP0190 warning from src/core/mcp-probe.ts is correctly left alone; note it and move on.

Acceptance

Same as before, and the first line still holds: a green CI run is not acceptance. For each item above the evidence is the machine's own output — the enumeration JSON, the config on disk, the process's stderr — pasted verbatim. A partial result with an honest list of what you could not settle is worth more than a claim that covers what you did not test.

@MongLong0214

Copy link
Copy Markdown
Owner

Correction to my own comment above, and what changed on the branch

My first hypothesis about Hermes was wrong, and I should have checked before offering it. I wrote that the Hermes step might have its own spawnSync that the new resolver does not reach, and that if so the fix would be small. It is not so. The merged source has:

const status = commandStatus(options.wrapper, ['hermes', 'install', ], 30_000);

commandStatus calls resolveCommand and spawnResolved — the new path, the same one that made Codex work. Hermes goes through the fix and still fails.

So the second branch is the one that holds: this is a third cause, unnamed, and cause 2 never covered it. #716's body attributing Hermes setup failed to the same shape as codex mcp add failed is wrong, and that attribution is now disproved rather than merely doubted.

The next step is not a fix. commandStatus uses stdio: 'ignore', so whatever Hermes said about why it failed was discarded — which is exactly why nobody can name the cause. Capture that output and paste it. A named third cause with its stderr is worth more than any guess, including the one I made.

Also unexplained: the gemini-cli first-run timeout

fresh   gemini-cli  failed  "registration was written but is unhealthy: MCP initialize timed out after 15000ms"
rerun   gemini-cli  owned, healthy

Same wiring, dead on the first attempt and alive on the second. This branch does not explain it. Whether a cold start genuinely exceeds 15s on that machine, or something is true only of the first run, decides whether the budget is wrong or something else is. "Run it twice" is not an answer a user can be given, so this needs either an explanation or an explicit statement that it is unexplained.

What I changed here

  • Fixes #716 cause 2Refs #716. GitHub's parser reads Fixes #716 and stops; cause 2 is not part of the reference, so merging would have closed the whole issue. With Hermes above, that would have been false in substance as well as mechanically wrong. The rest of the body is untouched.
  • Merged main. installer-hosts.ts merged with no conflict — the path-naming change and the execution-path change touch different sites. The merged file carries both plus the temporary-filename fix underneath, verified by grep rather than assumed.
  • Layered the canonical build (dist/ and installer/canonical-artifact.json), which is the part a Windows machine cannot produce. build:canonical then artifact:verify173d5ca2100676300bb5b94f8c600b482a93313b2e4a4e8ed5a1dd4fb95218c4. 26 cases pass across the five installer suites; tsc --noEmit clean.

Nothing about the Windows behaviour was judged by any of that. The live evidence on this pull request is still the only evidence for that path.

Release order

This is going out as 1.1.1, after 1.1.0 tags. It repairs behaviour that was broken rather than adding anything, so it is a patch. 1.1.0's notes state plainly that Windows host wiring does not work, which is true of 1.1.0 and stops being true when this lands — that is the release which gets to say so.

@MongLong0214

Copy link
Copy Markdown
Owner

Blocking: two trailer violations in cca98cc, and they are the whole failure

CI is red on lint, check (22.23.2) and check (24). All three are one causecheck fails through test/dogfood.test.ts ("this repository obeys its own protocol"), which runs the same validator lint does.

cca98cc:7:  enum Blast — got "installer host wiring", want "local|module|system"
cca98cc:10: format Record-Id — got "r-windows-host-exec", want "r-[a-z0-9]{6,}"
  • Blast: is a closed set of three. The blast radius of a change to one module's spawn path is module.
  • Record-Id: allows only [a-z0-9] after r- — no hyphens. r-winexec716 or similar.

Nothing else is wrong with the branch: 3154 cases pass, and the two action_required runs were the fork-PR approval gate, now approved.

This has to be your fix, not mine. Correcting it means rewriting your commit's message, and I do not force-push over someone else's commits. One reword and a lease-checked push:

git fetch origin
git rebase -i origin/main        # reword cca98cc only
git push --force-with-lease

The two commits above yours are a main merge and the canonical build; a rebase carries both through unchanged. Do this before the rest of the punchlist — nothing on this PR can be judged while the validator is red.

Note on the lint comment you will not see

##[warning]comment not posted: the token cannot write to this pull request
(a fork PR gets a read-only token) — the lint result is in the job summary

That is expected for a fork PR and not a defect. The violations are in the job summary, and above.

Punchlist, updated

0  reword cca98cc — Blast: module, Record-Id without hyphens     ← blocks everything
1  Refs #716 instead of Fixes #716                               done
2  merge main + canonical rebuild                                done (390b7f8, 3ba609e)
3  independent review of the cmd.exe quoting and PATHEXT resolver  in hand elsewhere
4  Hermes: capture the discarded stderr, name the third cause      yours, needs the machine
5  gemini-cli first-run timeout: explain or state it is unexplained  yours, needs the machine

Item 4 is worth restating after what the merge showed: Hermes already runs through commandStatusresolveCommandspawnResolved, the same path that fixed Codex, and it still fails. So the cause is not cause 2. commandStatus passes stdio: 'ignore', which throws away whatever Hermes said — that is the first thing to change, and the output is the finding.

@MongLong0214

Copy link
Copy Markdown
Owner

Review findings. Three, and the first one is the defect class this work exists to remove

An independent review of the spawn path came back. I checked each against the merged source before repeating it here — every code fact below is quoted from the branch, not relayed.

1. Blocker — the installer can report verified without verification running

const cmdArgument = (value: string): string | null =>
  /["%\0\r\n]/.test(value) ? null : `"${value}"`;

There is no handling for a trailing backslash, which every Windows directory path can carry. --data-root C:\Users\u\AppData\Local\commitlore\ becomes:

cmdArgument           "C:\Users\u\AppData\Local\commitlore\"
cmd.exe /s /c         strips the outer quotes, forwards the rest verbatim
the shim's %*         passes the tail through unchanged
CommandLineToArgvW    reads \" as an escaped quote, not a closing one
                      → argument absorbs the following ` --verify`

So --verify never arrives as a flag. The step then reads:

status === 0
  ? { host: 'hermes', outcome: 'installed', healthy: true, detail: 'Hermes setup verified' }

If the call exits 0 with the flag swallowed, this repository prints Hermes setup verified for a verification that did not run. That is precisely the shape #714 and #716 are about, arriving through the fix for them. A C:\ data-root triggers the same parse.

What I confirmed: the missing backslash handling, the parse consequence, and the status === 0Hermes setup verified mapping. What I did not: that hermes install exits 0 when --verify is absent — that is the reviewer's inference about an external tool. It is worth testing directly, because if it exits non-zero the bug is a confusing failure rather than a false success. Either way the quoting is wrong.

test/installer-windows-command.test.ts passes ['A&B', 'with spaces'] and never a path ending in \.

2. High — a regression on macOS and Linux, where this currently works

const isFile = (path: string): boolean =>
  statSync(path, { throwIfNoEntry: false })?.isFile() === true;

No executable-permission check. Before, hasCommand only tested existence and the subsequent spawnSync('claude', …) let the OS search PATH properly, skipping a non-executable file of the same name. Now resolveCommand returns that first match and the spawn runs that exact path, so one stale non-executable claude or codex earlier in PATH breaks Claude plugin installation and codex mcp on platforms where they work today.

This repository already has the stronger versionsrc/core/mcp-probe.ts:267 uses accessSync(candidate, constants.X_OK). The new resolver is a weaker copy of a solved problem, applied to a working path.

3. Medium — a legal % in a path is refused, and the reason is thrown away

% is legal in Windows filenames. A user named 100% Ready puts it in every path under LOCALAPPDATA, so every commandStatus(wrapper, …) call fails — Hermes, plugin install-codex, all of them.

And the reason does not survive:

if (invocation === null) return { status: null, error: new Error('batch command contains an unsafe cmd.exe character'),};

const commandStatus = () => resolved === null ? null : spawnResolved(resolved, args, ).status;   // .error dropped

Failing closed on %VAR% is the right instinct. The repair that also accepts real user paths is escaping (%%), not a flat refusal — and the error has to reach the caller either way. The test named "rejects values that cmd.exe would expand" asserts only ['%PATH%'].

And one test name is false

it('finds a command that exists only as a PATHEXT batch shim', …)
  writeFileSync(join(bin, 'codex'), 'not a Windows command shim');   // extensionless, also present
  writeFileSync(shim, '@echo off\r\n');                              // codex.CMD

The command does not exist only as a batch shim — the test writes both. It passes because the resolver never tries the bare name on Windows, so the extensionless file is inert. The name asserts a discrimination the test does not make. That is the third test in this repository this week whose name claimed more than its body.

What the review could not break, which is also a result

Injection through &, |, ^ and spaces inside the quoted form; !VAR! delayed expansion (/v:off with /d is correct); ", %VAR%, CR and LF rejected before spawn; PATHEXT precedence matching cmd.exe's own order; isFile() being stricter than !isDirectory(). The cmd.exe-over-shell: true design holds. The problems are one false success and one regression, not the approach.

Where this leaves the branch

Blocked, and worth taking time over. The three above plus the trailer reword in cca98cc are what stands between this and merging. Shipping it as-is would plant a new instance of the defect this release exists to remove.

1.1.0 goes out without it, saying plainly that Windows host wiring does not work. That statement is true today and stops being true when this lands correctly — which is the release that gets to say so.

@MongLong0214

Copy link
Copy Markdown
Owner

Addendum: finding 2 is a regression on the platform CI runs, and nothing here can fail on it

I checked whether any existing test would catch the missing X_OK. None would. Every installer suite creates its fixtures executable:

test/installer-hosts.test.ts:36    if (!isWindows) chmodSync(path, 0o755);
test/installer-hosts.test.ts:103   if (process.platform !== 'win32') chmodSync(codex, 0o755);

So a non-executable shadow earlier in PATH — the exact case that breaks — is never constructed, and the suite is green either way.

That matters more than the other two findings, because this one is not Windows-only. It regresses macOS and Linux, which CI does run, and CI still cannot see it. The green check jobs on this branch are not evidence about it.

Which also means it is the one finding a POSIX runner can be made to fail on. Concretely:

bin/claude       mode 0o644, not executable   ← earlier in PATH
real/claude      mode 0o755                   ← later
PATH=bin:real

resolveCommand('claude')  must return real/claude, not bin/claude

Before the fix that test fails, because the resolver returns the first file it finds. After it passes. That is the shape #717 used for the temporary-filename defect and the reason it holds: a guard that cannot fail where the code runs is decoration.

src/core/mcp-probe.ts:267 already does the check this needs — accessSync(candidate, constants.X_OK) — and it tries the bare name before the extension candidates, which is also what would make the 'exists only as a PATHEXT batch shim' test honest rather than accidentally passing.

Adding to the punchlist:

0  reword cca98cc — Blast: module, Record-Id without hyphens        blocks CI
1  trailing backslash in cmdArgument, with a path ending in \ in the test
2  X_OK in the resolver, with the non-executable shadow test above  ← fails on POSIX today
3  %% escaping instead of flat refusal; surface the error commandStatus drops
4  rename the PATHEXT shim test, or make it assert what it claims
5  Hermes: capture the stderr commandStatus discards, name the third cause
6  gemini-cli first-run timeout: explain it or record it as unexplained

Items 1 and 3 still need the machine to confirm the behaviour end to end. Item 2 does not — and it is the one currently breaking something that works.

MongLong0214 added a commit that referenced this pull request Aug 17, 2026
* Release 1.1.0

A machine can differ from the committed capture policy without modifying it. `.commitlore-policy.local.json` wins per key over `.commitlore-policy.json`, and `commitlore auto on|off --local` writes it, so a contributor who needs a different answer no longer has to leave a tracked file permanently modified -- the failure #709 reported was a release script refusing to tag a worktree dirtied exactly that way.

Also here: doctor stops telling a repository that a record was lost when nothing ever reached staging (#710); both installers carry only the code that runs, 845 lines of unreachable shell and PowerShell removed (#691); and one of the two Windows host-wiring defects is fixed, with every host failure now naming the file it read (#716).

Nineteen version surfaces move together: three manifests, five install pins in each of four READMEs, and three header examples in each installer. Two lines in README.md do not move -- "One installed before v1.0.2" and "Hooks installed from v1.0.2 onward follow upgrades on their own" are statements about a release boundary, and bumping them makes the README say something false. `test/readme.test.ts` reads only the install shapes, so nothing would have caught that.

The comment on REQUIRED_CHECKS gains its second half. It said why `lint` is excluded from the tag gate and stopped there, which reads as a hole: a reader on 2026-08-17 asked whether main could be pushed unlinted. It cannot -- `lint` is one of the eleven required status checks on main's branch protection, evaluated on the pull request head. The exclusion is about which contexts survive a squash onto main, not about whether the job ran. Included in this commit because it is documentation of the gate this commit is about to be judged by.

Limit: this release does not make host wiring work on Windows -- detection still cannot see a .cmd and spawn still cannot run one (#716)
Limit: 1.0.0 through 1.0.2 have no CHANGELOG entries; a pointer to the releases page stands in rather than reconstructing them
Ruled-out: fold the second Windows cause into this release | it arrived as #720 with real Windows evidence and needs its own judgement, and holding this back would make the note about what is still broken false in both directions
Blast: system
Undo: easy
Certainty: firm
Record-Id: r-rel110
Provenance: authored
Verified: 95 cases pass across manifest, readme and release-publish-prerequisites -- the last two caught install.sh and install.ps1 documenting themselves at 1.0.2, which the README bump alone had missed; the bump was dry-run on a scratch copy first and left both README.md prose lines untouched; dist needs no rebuild because the CLI reads its version at runtime
CommitLore-Version: 2.0.0

* Regenerate the canonical manifest the version bump moved

`artifact:verify` rejected the release commit with `source checksum does not match this checkout`. The manifest binds four source inputs -- `package-lock.json`, `package.json`, `tsconfig.json`, `src` -- and the version bump changes one of them, so a release commit always moves the digest even when nothing about the build does.

`dist/commitlore.mjs` is byte-identical, and that is the distinction worth keeping. The bundle comes from `src/`, which this release does not touch, and the CLI reads its version at runtime -- so "the release needs no dist rebuild" is true of the artifact and false of the manifest that describes it. The two get conflated because they live in the same commit and are regenerated by the same command.

Caught by CI rather than by the release checklist, which is the wrong way round for a step every release has to take.

Limit: this is the release commit's own repair, not a fix -- the next release will need the same regeneration for the same reason
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-relmanifest110
Provenance: authored
Verified: build:canonical then artifact:verify -> 2dcf0c4aeca193839f284c1b4fa5da57db8e29764ee02a36dad40d6768910101, and `git status` after the rebuild shows installer/canonical-artifact.json alone -- dist/ is unchanged by the bump
CommitLore-Version: 2.0.0

* Bump the fourth manifest the release gate reads

`check-release-version` refused the tag: `package-lock.json` carries the version twice -- `.version` and `.packages[""].version` -- and the release commit moved the other three manifests without it. The gate names both disagreements, which is how this was found rather than at the tag.

The bump is structural, not textual. A `"version": "1.0.2"` replacement across the file matches five times, and three of those are dependencies genuinely at 1.0.2 -- `call-bind-apply-helpers`, `side-channel-weakmap`, `wrappy`. Declaring them 1.1.0 corrupts what `npm ci` resolves, and the version tests would still have passed, because they only read the package's own two fields. Parsing the JSON and setting exactly those two is the operation that cannot reach the others.

`package-lock.json` is also one of the four inputs to the canonical source checksum, so the manifest moves again with it. `dist/commitlore.mjs` is still byte-identical: the lockfile records the same dependency tree it did before.

Limit: nineteen version surfaces was already wrong before this -- the lockfile makes it twenty-one, and the count is only ever known after the gate says so
Ruled-out: replace the version string throughout the lockfile | it matches four dependencies that are really at 1.0.2, and nothing in the suite would have caught it
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-rellock110
Provenance: authored
Verified: the diff is exactly two lines, both under the root package entry, with no node_modules path touched; npm ci resolves the tree unchanged; 104 cases pass across check-release-version, manifest, readme and release-publish-prerequisites; build:canonical then artifact:verify -> 2dcf0c4aeca193839f284c1b4fa5da57db8e29764ee02a36dad40d6768910101, unchanged from before the bump
CommitLore-Version: 2.0.0
@MongLong0214

Copy link
Copy Markdown
Owner

Item 2's guard belongs in the fixture helper, not beside it

Sharpening what I wrote above. The gap is not that this one case is unwritten — it is that the helper cannot express it:

// test/installer-hosts.test.ts:28
const wrapper = (root: string, name = 'commitlore'): string => {
  
  chmodSync(path, 0o755);      // hard-coded, no way to ask for anything else
  return path;
};

Every fixture this produces is executable. So no defect involving executability can ever be caught by this suite, and the missing X_OK is the instance that happened to land, not the last one that can.

If the fix adds only the one fixture — bin/claude at 0o644 before real/claude at 0o755 — it lands as a hand-built special case beside a helper that still cannot make one, and the next person reaches for the helper and falls into the same hole. Take a mode:

const wrapper = (root: string, name = 'commitlore', mode = 0o755): string => 

Then the assertion is the ordinary shape rather than an exception to it.

This is the same question as the rest of the punchlist, asked about the tests instead of the code: what is the green a statement about? For this branch it is not a statement about executable resolution, because nothing in the suite can produce a file that is not executable.

plutoparty1 and others added 4 commits August 17, 2026 12:56
Windows lookup examined only bare PATH entries, so it missed .cmd host shims, while shell-disabled spawning could not execute those shims. The installer now resolves one PATHEXT candidate for both detection and launching, and invokes cmd.exe with an explicit, checked argv body for batch files. This preserves shell:false and prevents wrapper or configured paths from becoming an unchecked shell command line.

Limit: Does not change MCP probing or rebuild generated artifacts.
Ruled-out: shell: true | wrapper and config paths must not become unchecked shell input.
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-winexec716
Provenance: authored
Verified: npx tsc --noEmit; installer tests 24 passed; real Windows installer readback reported a healthy Codex registration.
CommitLore-Version: 2.0.0
The branch deliberately shipped `src/` and `test/` only. CI binds the committed bundle to source by rebuilding twice in `docker linux/amd64 node:24-bookworm` and comparing bytes, and a Windows machine cannot produce that build -- so the artifact had to be added by a host that can, rather than guessed at or skipped.

Also brings `main` in. `installer-hosts.ts` merged without conflict: the path-naming change and the execution-path change touch different sites, and the merged file carries both plus the temporary-filename fix underneath.

Limit: this adds no judgement about the Windows behaviour -- it makes the branch buildable, and the live evidence on the pull request is still the only evidence for that path
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-canon720
Provenance: authored
Verified: 26 cases pass across the five installer suites; npx tsc --noEmit clean; the merged source carries atomicTemporaryName, the path-leading failure messages and the PATHEXT resolver together; build:canonical then artifact:verify -> 173d5ca2100676300bb5b94f8c600b482a93313b2e4a4e8ed5a1dd4fb95218c4
CommitLore-Version: 2.0.0
The first resolver fixed .cmd discovery but embedded every value in the cmd.exe program text. A data root ending in a backslash merged the final --verify token, and percent-bearing paths were refused, so verification could be skipped or a legal user path could fail. The same resolver treated any regular POSIX file as executable, letting a stale shadow mask the real host CLI.

Carry user values through one-use environment references, double trailing backslashes for the downstream argv parser, require X_OK, and propagate the first command failure line. Windows tests now capture the exact batch argv and Hermes stderr, while the Hermes path assertion parses YAML semantically on Windows.

Limit: Does not change executable-only Claude detection or the pre-existing MCP probe shell path; a new canonical dist rebuild is not included from Windows
Ruled-out: shell: true | wrapper and user paths would become a shell injection surface
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-winargv716
Provenance: authored
Verified: npx tsc --noEmit clean; cca98cc merged a trailing data root with --verify; current Windows argv and Hermes E2E passed; 44 installer and Hermes cases passed across the current source bundle and source suites; real enumeration reported Hermes installed healthy and preserved the Cursor failure
CommitLore-Version: 2.0.0
@plutoparty1
plutoparty1 force-pushed the fix-716-windows-exec branch from 3ba609e to 6a98a70 Compare August 17, 2026 04:00
@plutoparty1

Copy link
Copy Markdown
Contributor Author

Addressed the full updated punchlist and force-pushed with a lease check.

Commit records

  • Reworded the original commit: Blast: module, Record-Id: r-winexec716.
  • validate --range origin/main..HEAD --json now reports examined:4, violations:[].

Independent review findings

  1. Trailing backslash: confirmed and fixed. On the old commit the child expected ["...\\data root\\", "--verify"] but actually received ["...\\data root\\\" --verify"]. The current real Windows child receives the data root and --verify separately.
  2. POSIX X_OK: fixed. Resolution now uses accessSync(candidate, constants.X_OK). The Linux-only test places a mode-0644 shadow before a mode-0755 CLI and requires the latter.
  3. Literal percent paths: fixed without shell:true. Simple %% is not exact under /c (%%NAME%% became %EXPANDED% in the direct probe). The /c text now contains only random one-use environment references; one-pass expansion delivered literal 100% Ready and %PATH% through the actual .cmd shim.
  4. Failure reason: preserved. A wrapper exiting 23 with missing Hermes skills sentinel on stderr now yields Hermes setup failed: missing Hermes skills sentinel.
  5. PATHEXT test: corrected. The fixture now creates only codex.CMD.

The same E2E also delivered A&B, A|B, A^B, !PATH!, spaces, and a trailing-backslash path exactly.

Hermes finding

There was no second missed spawn site. The previous temporary test package omitted shipped hermes/skills. With that directory absent, the current bundle exited 2 with:

could not find the CommitLore Hermes skill bundle at C:\Users\u\AppData\Local\Temp\commitlore-716-e2e-final\hermes\skills

Adding only that shipped directory made the identical command exit 0. Correct-layout current source now reports Hermes installed/healthy and writes the real config entry.

Final real-machine receipt

{"schema":"commitlore_installer_hosts.v1","runtimeIdentity":{"version":"1.0.2","entrypoint":"C:\\Users\\u\\AppData\\Local\\Temp\\commitlore-716-e2e-final\\dist\\commitlore.mjs","packageRoot":"C:\\Users\\u\\AppData\\Local\\Temp\\commitlore-716-e2e-final","indexSchemaVersion":4},"ok":false,"hosts":[{"host":"codex","requested":true,"outcome":"custom-preserved","healthy":true,"detail":"healthy custom registration preserved; plugin installed"},{"host":"gemini-cli","requested":true,"outcome":"custom-preserved","healthy":true,"detail":"healthy custom registration preserved"},{"host":"cursor","requested":true,"outcome":"failed","healthy":false,"detail":"C:\\Users\\u\\.cursor\\mcp.json is not parseable JSON: Unexpected end of JSON input"},{"host":"hermes","requested":true,"outcome":"installed","healthy":true,"detail":"Hermes setup verified"}],"notDetected":["windsurf","opencode","claude-code"]}

Disk readback agrees:

  • Codex and Gemini registrations still name their recorded .cmd plus mcp; both returned CommitLore MCP Initialize and probe exit 0.
  • Hermes names the current staged .cmd, mcp, enabled true, and the shipped skills directory; MCP Initialize and probe exit 0.
  • Cursor remains exactly 0 bytes and failed. Nothing was written.
  • Claude remains executable-only: .claude.json exists, Get-Command claude returns none, so notDetected is expected. I did not change this on a guess.
  • Windsurf and OpenCode are genuinely absent.

Gemini's initial 15-second timeout did not reproduce in four corrected-layout runs: 4/4 healthy. It remains a one-off observation, not an explained failure.

Verification: tsc --noEmit clean; current Windows boundary 7 passed (POSIX-only case skipped); source-level installer/Hermes 32 passed (one POSIX-only skip); spawned current-source installer suite 12 passed. The canonical build commit was preserved byte-for-byte, but this new source commit deliberately does not regenerate it on Windows, so the Linux canonical layer must be refreshed once more.

# Conflicts:
#	installer/canonical-artifact.json
@plutoparty1

Copy link
Copy Markdown
Contributor Author

Follow-up after main advanced with #721:

  • merged current origin/main as 5d4fc30; the only conflict was installer/canonical-artifact.json, resolved to the exact 1.1.0 main copy rather than generating anything on Windows;
  • PR merge conflict is removed locally and the branch is pushed;
  • validate --range origin/main..HEAD --json now reports examined:5, violations:[];
  • fresh workflow runs CI 31993345650 and lint 31993345762 are both action_required, awaiting fork-workflow approval;
  • the Linux canonical rebuild still needs to be refreshed after the new source commit, as noted in the PR body.

I am monitoring new comments and checks.

@plutoparty1

Copy link
Copy Markdown
Contributor Author

Post-approval CI result:

  • PR lint: passed. The corrected Blast and Record-Id trailers are accepted.
  • Node 22.23.2 and Node 24: dependency floor, source typecheck, and bench typecheck all passed.
  • Both check jobs stopped only at Canonical dist is reproducible and matches its manifest, exactly because the new source commit has not been rebuilt by the Linux canonical builder yet. Unit/dogfood steps were therefore skipped, not failed.
  • All other completed jobs passed: audit, git matrix on Ubuntu/macOS, install-macos, install-script, install-ps1, and Alpine amd64/arm64.

No new code or history failure surfaced. The remaining merge gate is the owner/Linux canonical rebuild followed by the check rerun.

The branch carries the review fixes -- trailing backslashes doubled, executable resolution gated on X_OK, the percent handling that survives one-pass expansion -- and the committed bundle still described the source before them. CI compares the committed dist against two `docker linux/amd64 node:24-bookworm` builds and against the manifest, so the branch could not go green without a build from a host that can produce one.

The 1.1.0 release moved through here in between: `package.json` is one of the four canonical source inputs, so the manifest digest moved for the version bump as well as for the source change. Resolving that conflict to main's copy on the Windows side was right -- the alternative is generating a Linux artifact on a machine that cannot, which is a guess wearing a checksum.

Limit: this rebuilds, it does not judge -- the Windows behaviour still rests on the live evidence recorded on the pull request, and Hermes and the first-run probe timeout are still open on MongLong0214#716
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-canon720b
Provenance: authored
Verified: build:canonical then artifact:verify -> 14ff61683da747382e03b026db252dd06d4f20820c3ab6f6a75282837458429d; 27 cases pass across the five installer suites, including the POSIX-only case that places a mode-0644 shadow ahead of a mode-0755 CLI and requires the latter
CommitLore-Version: 2.0.0
@MongLong0214

Copy link
Copy Markdown
Owner

Canonical rebuild is in, and CI is approved and running

9a44c88 pushed to this branch. That was the last gate you named, and it is the one a Windows machine cannot produce, so it was mine to do:

build:canonical  →  artifact:verify  →  14ff61683da747382e03b026db252dd06d4f20820c3ab6f6a75282837458429d
27 cases pass across the five installer suites (1 skipped: the Windows-only case)

I also approved the two fork-workflow runs. Read this next part before your next push, because it will keep costing you round trips otherwise: a fork PR's runs land as action_required and report no checks at all until a maintainer approves them. gh pr checks prints "no checks reported on the branch" — which is neither passing nor failing, and is easy to read as "CI has not started yet". It has not, and it will not on its own. Ping here after a push and I will approve.

Resolving the manifest conflict to main's 1.1.0 copy was the right call. Generating a Linux artifact on Windows would have been a guess wearing a checksum, and the check that compares it would have caught it — but only after costing another cycle.

Your five fixes, checked against the source

I read each in the branch rather than taking the summary:

finding where
trailing backslashes doubled installer-hosts.ts:253, with the reason in the comment — the closing quote consumes one slash per trailing pair
X_OK on resolution isExecutableFile at :210, used by resolveCommand, so detection and execution agree
literal % without shell:true rationale recorded in the code
failure reason preserved Hermes setup failed: <stderr>
PATHEXT fixture writes only codex.CMD now

Two of these are worth naming for what they did rather than what they fixed.

The percent handling. You did not implement the suggestion. You tested it, found %%NAME%% still expands under /c, and recorded why — so the next person who proposes %% in six months meets the measurement instead of repeating the work. Rejecting a review finding with evidence is worth more than adopting one without.

hasCommand now goes through resolveCommand. The original defect was detection and execution disagreeing about what "present" means. They are now one function. The place a thing is decided and the place it is enforced are the same place.

And the X_OK test is the one that had to exist: a mode-0644 shadow ahead of a mode-0755 CLI, running on POSIX. That regression would have been invisible to every runner this project has — the fixtures are all executable — so without it a future revert would be silent.

Still open on #716, and not blocking this PR

  1. Hermes. It runs through commandStatusresolveCommandspawnResolved, the same path that fixed Codex, and still failed on your machine. So it is a third cause, not cause 2 — Windows: every detected host fails to wire — the temp filename carries the whole path, and hasCommand cannot see a .cmd #716's body attributing both to one shape is disproved. Your change now preserves the stderr, which is exactly what was missing: the next real run should print why. Paste it.
  2. gemini-cli first-run timeout. MCP initialize timed out after 15000ms on the fresh install, owned/healthy on the rerun. Same wiring. Whether a cold start genuinely exceeds 15s there or something is true only of the first attempt decides whether the budget is wrong or something else is. "Run it twice" is not an answer a user can be given.

Both need the machine, and both are worth a re-run once this is green — against v1.1.0, which is now tagged, so the observation is quotable rather than being about a moving main.

One small thing left from my earlier note, not blocking: test/installer-hosts.test.ts's wrapper() helper still hard-codes chmodSync(path, 0o755). Your new test builds its fixture by hand in the other file, which works — but the helper still cannot express a non-executable file, so the next person reaching for it lands in the same blind spot.

@MongLong0214

Copy link
Copy Markdown
Owner

What closing this out actually requires

Four issues are open. Two of them can only be closed from your machine, and this comment is the place we coordinate that — so here is each one with the evidence that closes it, rather than a list of tasks.

#716 — two of its threads are answered, two are not

Answered and not worth re-litigating:

  • cause 1, the temporary filename carrying the whole path — fixed in v1.1.0, released.
  • cause 2, PATHEXT and the .cmd shim — fixed on this branch, with your Codex and Gemini readbacks as the evidence.
  • cursor — your 0-byte finding settled it. %USERPROFILE%\.cursor\mcp.json was genuinely empty, so JSON.parse('') was the true reason and the installer's message was right. My doubt about that was wrong; the record says so now.

Open, and both need the machine:

1. Hermes — a third cause, currently unnamed.

It runs through commandStatusresolveCommandspawnResolved, the same path that made Codex work, and it still fails. So #716's body attributing it to cause 2 is disproved, not merely doubted. Until this change, stdio: 'ignore' threw away whatever Hermes said — your change preserves it, so the next real run should print the reason instead of Hermes setup failed.

What closes it: one run on v1.1.1 with the stderr pasted verbatim. Then either it is ours and we fix it, or it is a Hermes-side condition and #716 records that and closes. Both outcomes close the thread; only silence does not.

2. gemini-cli first-run timeout.

fresh   failed  "registration was written but is unhealthy: MCP initialize timed out after 15000ms"
rerun   owned, healthy

Same wiring, dead on the first attempt and alive on the second. What closes it: does it reproduce on a fresh install? If yes, the 15s budget is wrong for a cold start on that hardware and we have a number to raise it to. If it does not reproduce, say so and it is recorded as a one-off — which is a worse answer but an honest one. "Run it twice" is not something we can tell a user, so the one thing that cannot happen is leaving it undescribed.

#714 — closes on one readback, not on a fix

That issue exists because host wiring had never been observed working on Windows. It is currently titled Verified, and it fails. When a run on v1.1.1 shows at least one host installed/owned and healthy with the entry on disk, that title stops being true and the issue closes on your paste. Nothing else is required of it.

#722 — the one that stops this recurring

Filed just now: plant a fake cursor.cmd on PATH in the Windows job so the wiring branch actually executes in CI. Every defect above was found by you on a real machine because CI has no agents and short-circuits at detection — "hosts":[] on every run, which is why #714 had to exist at all.

Not yours unless you want it: it is a workflow change, and windows-latest already runs in CI, so it does not need your machine. It does need one property to be worth anything — the fixture must be able to fail. A stub that always registers cleanly proves the installer reports what the stub told it, which is the empty runner again with more steps.

#719 — stays open on purpose

The committed dist bundle conflicting between open PRs. Known, measured, deferred. You hit it twice today; that is expected and recorded.

Sequence

1  this PR goes green            check ×2 running now on 9a44c88
2  it merges as v1.1.1           I cut the release
3  you run the installer from the v1.1.1 tag on the machine
4  paste: full enumeration JSON, the Hermes stderr, whether gemini repeats
5  #714 closes on that paste; #716 closes on 4 or gets a named third cause

Run against the tag, not main. main moves; a tag does not, and an observation written as "on v1.1.1, this happens" is quotable in an issue while "on main after #720" is not. That is the same reason the earlier packet pinned v1.1.0 — and it is why the v1.0.2 evidence you started from was usable at all.

I will approve the fork workflow runs after each push — they land as action_required and report no checks until then, which reads as "CI hasn't started" and never resolves on its own. Ping here and I will clear it.

@MongLong0214
MongLong0214 merged commit 7bf5b82 into MongLong0214:main Aug 17, 2026
12 checks passed
@MongLong0214

Copy link
Copy Markdown
Owner

Green on all eleven required checks — and preserve caught something that changes how this merges

audit · check (22.23.2) · check (24) · git-matrix ×2 · install-macos
install-script · install-ps1 · install-alpine ×2 · lint          all pass
preserve                                                          fail

preserve is not a required check, so it does not block the button. Read its output before using it:

merge-type=squash  records=4  conflicts=0  pushed=false
ERROR: could not publish refs/notes/commitlore to origin …
  remote: Permission to MongLong0214/commitlore.git denied to github-actions[bot].
  → the record was attached on the runner and is about to be discarded with it

A fork PR's workflow gets a read-only token. So a squash-merge of this pull request would collapse six commits into one and lose all four records with them — in the repository whose entire purpose is that records survive a squash. Every required check would still be green while it happened.

Nothing on your side caused it and nothing on your side can fix it; it is a property of forks. Filed as #723.

So this merges as a merge commit, not a squash. The records live in the commit messages, which is their primary home — notes are the mirror for history that has been squashed away. A merge commit keeps the commits, so there is nothing to mirror and nothing to lose. Same reason #717 used one, different cause.

Pre-merge verification

Read from the branch rather than from the summaries:

commits                    6, validate --range → shape ok · references ok
trailing backslash doubled installer-hosts.ts, with the reason in the comment
X_OK gate                  isExecutableFile, used by resolveCommand
percent rationale          recorded in the code
stderr preserved           Hermes setup failed: <reason>
POSIX X_OK guard           mode-0644 shadow ahead of a mode-0755 CLI
manifest                   14ff61683da747382e03b026db252dd06d4f20820c3ab6f6a75282837458429d

The reworded trailers validate — that was what had lint and dogfood red, and both are green now.

Next

Merging now, then cutting v1.1.1. When the tag exists I will post it here.

Then the part only your machine can do, against the tag:

  1. install from the v1.1.1 tag as a user would;
  2. paste the full enumeration JSON — that readback closes Verified, and it fails: installer-hosts wires nothing on a real Windows machine #714;
  3. paste the Hermes stderr, which your change now preserves. Whatever it says names the third cause, and Windows: every detected host fails to wire — the temp filename carries the whole path, and hasCommand cannot see a .cmd #716 closes either way — ours to fix, or a Hermes-side condition recorded as such;
  4. say whether the gemini-cli first-run timeout repeats on a fresh install.

Item 4 is the one where "it did not reproduce" is a legitimate answer. Items 2 and 3 are not answerable from anywhere else.

@MongLong0214 MongLong0214 mentioned this pull request Aug 17, 2026
@plutoparty1

Copy link
Copy Markdown
Contributor Author

@MongLong0214 I addressed the remaining fixture-helper note in eefaa84 and pushed it.

  • test/installer-hosts.test.ts's shared wrapper(root, name, mode) now accepts the mode, defaulting to 0o755.
  • The POSIX X_OK regression is no longer a hand-built exception in the Windows-command test. It now asks that shared helper for the 0o644 shadow and 0o755 real executable.
  • This is test-only. The canonical source inputs (package.json, lockfile, tsconfig.json, src) and the canonical artifact from 9a44c88 are unchanged.
  • Local Windows verification after the move: tsc --noEmit clean; all six installer suites: 29 passed, 1 skipped (the POSIX-only executable-mode case); CommitLore validation of the new commit: violations: [].

Please approve the new fork workflow run when it appears.

On the two machine-only follow-ups: I agree the quotable closure must be the released v1.1.1 tag. For clarity before that run, the corrected-layout current-source run already showed Hermes installed, healthy:true; the earlier stderr was exactly the incomplete temporary package omitting the shipped hermes/skills directory. Gemini was healthy in 4/4 corrected-layout reruns and the original 15-second timeout did not reproduce, so it remains explicitly unexplained rather than claimed fixed. As soon as you merge and cut v1.1.1, I will run the tag on this machine and paste the full enumeration JSON, disk readback, Hermes result/stderr, and fresh Gemini observation on #714/#716 as requested.

@plutoparty1

Copy link
Copy Markdown
Contributor Author

@MongLong0214 blocking release observation: the merge did not use the merge-commit path described in your last comment.

Runtime/API evidence:

merge SHA: 7bf5b82b0671593d3c7f425273b215bd27f8511e
parents:   [d9a041fd970d8a44c79f569442507157b7bb69a5]   # exactly one
message:   "Squashed. Six commits including two main merges and a canonical rebuild."
preserve:  attached 4 record(s), then push-failed with github-actions[bot] 403

So the state is the failure your comment intended to avoid: the six branch commits were collapsed, and the four records that preserve reconstructed were discarded with the runner. This is not caused by the Windows fix, but it is real repository state, not just a red optional check.

The source branch history is still recoverable from plutoparty1/fix-716-windows-exec; 9a44c88 is the exact pre-merge reviewed/canonical head. Please do not cut v1.1.1 until the notes are republished with a write-capable credential or you choose another explicit recovery recorded on #723. I cannot push refs/notes/commitlore to the upstream repository with my fork credentials.

Also, eefaa84 was pushed after GitHub had already merged 9a44c88, so the fixture-helper cleanup in my immediately preceding comment is not in main. It is test-only and can be cherry-picked or taken as a tiny follow-up PR after the record recovery decision.

@plutoparty1

Copy link
Copy Markdown
Contributor Author

Recovery confirmed from the upstream ref, so my release block above is cleared:

refs/notes/commitlore = 6987d6ad554a816c7f2c9c2b9a16e302da639a33
note target            = 7bf5b82b0671593d3c7f425273b215bd27f8511e
inherited records      = r-winexec716, r-canon720, r-winargv716, r-canon720b
conflicts              = 0

I fetched the upstream notes ref independently and read the target note. It contains all four inherited blocks with their source SHAs, so the runner loss has been repaired and the pre-release record state is now sound. v1.1.1 is not visible yet; I am ready to start the tag-pinned Windows install as soon as it appears.

@plutoparty1

Copy link
Copy Markdown
Contributor Author

Released-tag closure receipt: v1.1.1 was installed through its actual Windows installer path. Fresh-profile Gemini and Hermes both returned installed, healthy:true; their entries were read from disk and the recorded v1.1.1 wrapper passed a separate live MCP probe. The Gemini first-run timeout did not reproduce. Existing-profile Hermes now reports the precise custom-ownership mismatch instead of a generic failure.

Full JSON and paired disk evidence: #716 (comment)

@MongLong0214

Copy link
Copy Markdown
Owner

Correcting the merge commit for this PR. 7bf5b82b says:

hermes already routes through the new spawn helper and still fails, so its failure is a third cause with no name yet

That is wrong, and #726 has the right account: hermes works. The trailing backslash was the hermes failure — --data-root ends with one on Windows, so it was the argument whose closing quote got consumed and --verify was what got absorbed. Fixing the quoting is what made the step pass. There is no third cause.

How I got it wrong: the PR body carries three JSON captures and hermes reads failed in all three. A later run in this thread said otherwise, and I wrote the merge message from the body without checking whether a newer artifact had been posted. The deciding evidence was already here.

A merge commit cannot be edited, so this comment is the correction of record — anyone reading git log will hit that paragraph and have no reason to doubt it.

MongLong0214 added a commit that referenced this pull request Aug 17, 2026
* Let a fork's records survive their own squash (#723)

`preserve` ran on `pull_request`, which hands a fork's workflow a read-only token. So the job read the branch's commits, built the inherited note correctly, and could not publish it:

    merge-type=squash  records=4  pushed=false
    → the record was attached on the runner and is about to be discarded with it

That is what happened to #720. Four records were lost at the merge and recovered by hand afterwards, in the repository whose whole claim is that records survive a squash. `contents: write` was already declared; it cannot grant what the event does not carry.

`pull_request_target` carries it. That event is better known for handing an attacker a write token, and the mechanism is specific: it checks out the base by default, and the hole opens when a workflow points the checkout at the fork's head and then runs it.

This job never has. It checks out `base.ref` -- for an unrelated reason, because `refs/pull/<n>/merge` is gone once the pull request closes -- installs and builds from that tree, and brings the fork's commits in as `refs/commitlore/pr-head`, which is read for trailer blocks and nothing else. Data, never scripts. The change is one line; what makes it safe was already true.

Being already true is not the same as staying true, so the two rules are asserted rather than requested: the checkout must name the base branch, and every command mentioning the fork ref must be a `git fetch`. Reverting the event, pointing the checkout at the head, or running anything out of that ref each fail a named case.

Limit: this runs on close, so it publishes rather than gates -- a merge that could not carry its records still happens first, and the report is the only thing standing there
Limit: the fetched ref is trusted as data by every consumer downstream; a future step that treats a commit message as a command would reopen this by a different door
Ruled-out: make preserve a required check | it runs on the close event, so it cannot be a merge gate at all
Ruled-out: a maintainer runs squash-preserve after each fork merge | it is what I did for #720, and a manual step nobody is reminded of fails on the fifth occurrence rather than the first
Blast: system
Undo: easy
Certainty: firm
Record-Id: r-forkpreserve723
Provenance: authored
Verified: three mutations, each caught by the case whose name claims it -- reverting to `pull_request` fails the trigger case; pointing the checkout at `head.sha` fails the base-branch case; adding `git checkout refs/commitlore/pr-head -- .` before `npm ci` fails three, including the one asserting every mention of that ref is a fetch. Six cases pass on the file as it stands; the assertions read the workflow with comment lines stripped, so a comment cannot satisfy them.
CommitLore-Version: 2.0.0

* Let the dogfooding case read the trigger that is there

`action-preserve` asserted `on.pull_request.types` and #723 moved the workflow to `pull_request_target`, so the case died on `Cannot read properties of undefined` rather than on anything it was checking.

What it was checking is unchanged: the job fires on the closed event and nothing else. It now reads whichever key carries that. Which key it should be, and the two rules that keep the stronger event safe, are asserted in `test/preserve-workflow-safety.test.ts` -- a property belongs in one place, and that place is the file named after it.

Limit: this is the third guard this week whose subject moved out from under it; the pattern is a case pinned to a spelling rather than to the thing spelled
Blast: local
Undo: easy
Certainty: firm
Record-Id: r-trigkey723
Provenance: authored
Verified: 37 cases pass across action-preserve and preserve-workflow-safety together; before this the check jobs failed with `Cannot read properties of undefined (reading 'types')`, which named the spelling and not the property
CommitLore-Version: 2.0.0
MongLong0214 added a commit that referenced this pull request Aug 17, 2026
…731)

* Plant a host in Windows CI so the wiring branch actually runs (#722)

A GitHub runner has no coding agents, so detection short-circuits and the Windows job has printed `"hosts":[]` on every run it has ever made. Every branch that wires a host is unexecuted there -- not under-tested, unreachable -- and an assertion reading that list passes whether the wiring code works, is broken, or has been deleted.

That vacancy is why #714 existed as a standing record rather than a task, and why both Windows defects in #716 were found by a person on a real machine instead of by any job here. The response until now was to make the guards falsifiable somewhere the code does run: #717 pinned a temporary-filename property as a pure function, #720 added a mode-0644 shadow for executable resolution. That works and it is second best, because it proves properties of helpers rather than executing the path.

A planted `cursor.cmd` removes the vacancy instead of working around it. Detection looks for the command on PATH, and nothing here executes Cursor -- Cursor plays no part in wiring, since what gets written is its config and what gets probed is CommitLore's own wrapper. So the fixture is a file, and the branch it unlocks is the real one, end to end: detection, the atomic config write, and a live MCP initialize.

Two cases, and the second is what makes the first mean anything. A fixture that can only succeed proves the installer reports what the fixture told it, which is the empty runner again with more steps. So the second gives the same detected host a config it cannot parse and requires `outcome: "failed"`, `ok: false`, the file named in the reason, and the unusable config unmodified afterwards.

The suggestion and its framing -- so this class cannot hide behind an empty runner again -- came from kantorcodes1 in a public discussion. It is a better answer than the one I was giving.

Limit: one host of seven, on one platform. gemini-cli, windsurf and opencode take the same JSON path and are not planted; codex, hermes and claude-code take different ones and are not covered at all
Limit: this executes wiring, not a real agent -- a planted shim is a file the job wrote, and it says nothing about what Cursor itself does with the registration
Ruled-out: assert on the existing `"hosts":[]` output | it is the same on a working installer and a deleted one, which is the defect this replaces
Ruled-out: a stub that always registers cleanly | it proves the installer reports what the stub told it, and cannot fail, so it restores the vacancy under a new name
Blast: system
Undo: easy
Certainty: firm
Verified: the workflow parses as YAML and the job gains one step; `EXPECTED_CI_WORKFLOW_SHA256` is re-locked to the edited file, which is the mechanism that would otherwise let a workflow body change unreviewed, and 29 release-prerequisite cases pass against it. The step itself cannot be run locally -- it needs windows-latest, which is the whole point -- so its first execution is this pull request's own `install-ps1` job.
Record-Id: r-plantedhost722
Provenance: authored
CommitLore-Version: 2.0.0

* Assert the exit code the planted host was already producing

The step's first run did everything it claimed. On a GitHub runner, with no coding agent installed, the enumeration wired a host and live-verified it:

    ok:true   cursor  installed  healthy  "registration created and live-verified"
    ok:false  cursor  failed     "D:\a\_temp\cl-host-bad\.cursor\mcp.json is not parseable JSON..."

The job still failed, and the reason is worth keeping rather than papering over. GitHub's pwsh shell exits with the last native command's status, and the second case deliberately produces a failing one -- so the step was reporting, accurately, that a host had failed.

That is a property nothing asserted. `install.ps1` returns this command's status, so a host that failed must make it non-zero or an install reports success over a repository with nothing wired -- the exact claim the installer is for. Both codes are now checked: zero when the host wired, non-zero when it did not, and an explicit `exit 0` at the end because every assertion throws before reaching it.

Limit: the second case's non-zero code is asserted, not its value -- a change from 1 to 2 passes here
Blast: module
Undo: easy
Certainty: firm
Record-Id: r-plantedexit722
Provenance: authored
Verified: the run this repairs is the evidence -- both enumerations are in its log with the outcomes the step requires, and the only thing that failed was the shell's exit status; the workflow parses, the digest is re-locked to it, and 29 release-prerequisite cases pass
CommitLore-Version: 2.0.0
MongLong0214 added a commit that referenced this pull request Aug 18, 2026
The first shape of this let a wrong commit reach `main` and opened a second pull request to fix it. That does not meet what T-1502 asks for -- "a source-only pull request merges and the commit that lands on `main` passes `artifact:verify` and `git diff --exit-code -- dist/` without anyone rebuilding by hand" -- because the commit that lands is the wrong one, briefly, every time. It was also building a file T-1502 owns while T-1501 puts "any workflow file" out of scope.

So it builds the landing commit instead: `main` plus the pull request's source plus a canonical rebuild, on one branch, opened as one pull request. All eleven required contexts run on the tree that actually merges rather than on one that resembles it, and a contributor who cannot produce a `linux/amd64` Docker build waits for nothing (#720).

Three things the review of the first shape found, each fixed here rather than argued with.

The concurrency group orders jobs, not merges. My own comment claimed it removed a race and then described that race: a rebuild is of `main` as it stood when the job started, and nothing stops another pull request landing while it runs. There is now a check between the rebuild and the push that refuses when `main` moved.

A rebuild branch cannot be updated, only replaced. `main`'s protection is `strict`, and `.gitattributes` marks `dist/**` as `-merge`, so GitHub's "Update branch" conflicts on the one file the job exists to produce. The branch is keyed to the pull request rather than to a commit, and a rerun force-pushes it.

And the guard's own reading was wrong on merge commits. `git show --name-only` prints a merge's combined diff -- only paths differing from every parent -- so a clean merge reports nothing at all. `git show --first-parent` reports seven paths for `5dcc02b` where the plain form reports zero. The suggested replacement, `diff-tree --first-parent`, also reports zero; it was measured rather than adopted.

The safety property with no precedent here is the order of two steps. The rebuild runs `npm ci` and `build:canonical` on the merged tree, which executes whatever the pull request put in `package.json`. The App token is minted after that, and is not in that step's environment: a rebuild of somebody else's change must not be able to read the credential that lets it push. `test/canonical-merge-workflow.test.ts` asserts the ordering, with comments stripped so the explanation cannot satisfy it.

Limit: this leaves pull requests still carrying `dist/` when they choose to -- T-1503 is what stops requiring it, and until then a source-only pull request is a contributor's option rather than the rule
Blast: system
Undo: easy
Certainty: firm
Record-Id: r-canonmergewf
Provenance: authored
Verified: two negative controls fail the intended assertion and pass after restore -- moving the token mint above the rebuild, and deleting the source-only refusal; 79 tests across the five workflow suites pass, and the guard's `--first-parent` reading was checked against this repository's real merge commit rather than a constructed one
CommitLore-Version: 2.0.0
MongLong0214 added a commit that referenced this pull request Aug 18, 2026
…anual

T-1502 asks that "a source-only pull request merges and the commit that lands on `main` passes ... without anyone rebuilding by hand". This job opens a second pull request, so the first half looked unmet -- and it is met by what lands rather than by what is clicked.

The branch merges the contributor's head with `--no-ff`, so their commit is an ancestor of it. Merged with a merge commit, that commit lands on `main` and GitHub closes their pull request as merged. Squashed, new bytes land instead and their pull request stays open pointing at nothing. This repository allows both, so the method is part of the contract rather than a preference, and the bot now says so in the body it writes. A test pins both halves: the `--no-ff` and the instruction.

The reason this is dispatched by hand also changed, and saying so matters more than the trigger does. It was security -- an automatic trigger would let a fork's push decide when the App token sits in an environment building that fork's code. The job split removed that: the token is never in the job that runs contributor code. What is left is cost, one Docker `npm ci` and a canonical build per dispatch, which a pull-request trigger would run on every push from anybody.

Leaving the old reason in place would have been the worse outcome. A guard whose stated reason has been fixed elsewhere is one somebody removes later on the grounds that the reason no longer holds, without noticing it acquired a different one.

And `without a maintainer` in PRD-F15's success line means without a maintainer *rebuilding* -- what #720 waited on twice. Nobody rebuilds here.

Limit: the merge method is stated and asserted in the body, not enforced -- a squash merge of a canonical pull request still works, it just leaves the contributor's pull request open, and nothing fails when somebody does it
Blast: system
Undo: easy
Certainty: firm
Record-Id: r-mergemethod
Provenance: authored
Verified: `allow_merge_commit` is true on this repository, so the method is a choice rather than a constraint; replacing `--no-ff` with `--squash` fails the new assertion, and 15 of 15 pass restored
CommitLore-Version: 2.0.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants