Skip to content

fix(harness): converge the deploy engine — prune, drift detection, all six surfaces - #948

Merged
mlorentedev merged 7 commits into
mainfrom
fix/harness-deploy-convergence
Aug 13, 2026
Merged

fix(harness): converge the deploy engine — prune, drift detection, all six surfaces#948
mlorentedev merged 7 commits into
mainfrom
fix/harness-deploy-convergence

Conversation

@mlorentedev

@mlorentedev mlorentedev commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Three related gaps in harness deploy convergence, closed together as one thread (spec: specs/HARNESS-070-deploy-convergence/):

Bonus, found while investigating this session's handoff evidence: the "4 symlinked skills" reported as a BUG-100 regression (computer-use, find-skills, orca-cli, orchestration) turned out to be a false positive — all four resolve to Orca's own ~/.agents/skills mechanism, unrelated to this repo's harness/skills/ records, with documented prior art for the exact exclusion (specs/archive/AI-022-pi-harness-slot/). Filed and closed as #943 (BUG-074) in the same change.

Also fixed a pre-existing bug found while sandbox-testing AC3: deploy_agent_presence always logged [deploy] presence -> ... as success even when the injection was skipped because the target file didn't exist, producing a directly contradicting pair of log lines.

Not in this PR (stated, not silently dropped)

  • OPS-025: a merged guard never reached the machine — self-deploy is opt-in and nothing reports the drift #869 remedy (a)DOTFILES_AUTODEPLOY disposition (opt-in vs. on-by-default) is a per-machine judgment call for @mlorentedev, not a code change. Remedy (b), the durable guard, is what this PR ships.
  • BUG-051: the sensitive/ deploy never prunes - a deleted secret stays decryptable on every machine, forever #802 — the same copy-only-no-prune gap on scripts/, sensitive/, .zsh/, ssh/, secrets/ (36+ orphan files measured on a live machine). Referenced, not closed — out of this session's assigned surface (scripts/compile-harness.sh, harness/, the doctor package).
  • bats coverage for the shell-side --deploy change — tests/*.bats was out of scope this session (three parallel sessions touching the repo). Verified instead by: shellcheck/bash -n/zsh -n clean, all 44 existing tests/compile-harness.bats + 64 tests/setup-linux.bats cases still pass unmodified, and a manual sandboxed HOME=/tmp/fake-home --deploy run (idempotent, region-injection-safe). Proposing a follow-up ticket to add the bats regression — not filing it unilaterally.
  • Windows parity — the new agents.presence[].source field is consumed only by compile-harness.sh (Linux). setup-windows.ps1 is unaffected (per AC3) and never had this specific gap: it sets $DotfilesDir = $PSScriptRoot, i.e. the deploy dir IS the checkout, so there's no lightweight-deploy-vs-full-setup split to begin with on that side.

Current machine state (informational, not actioned by this PR)

Running dotf doctor on this machine right now still shows the drift this PR's checks were built to catch — expected, since fixing the detection doesn't retroactively converge the machine:

  • dotf itself is one release behind (installed=0.37.0 pinned=0.38.0) — now a FAIL, was a WARN.
  • 6 files differ repo↔~/.dotfiles mirror (9 PRs + a release cut since the last full setup run).
  • 3 instruction files are stale (.claude/CLAUDE.md, opencode/pi AGENTS.md).

Remediation, in order, left for @mlorentedev to run when convenient: merge this PR → next release cuts → ./scripts/install-dotf.shsetup-linux.sh (or compile-harness.sh --deploy for just the harness surface) → dotf doctor should read clean. Worth noting for the #869 story: the new guards in this PR are themselves live only after that next release installs — which is the OPS-025 window working exactly as described.

Test plan

  • cd cli && go build ./... && go vet ./... && go test ./... — 13 packages, all green
  • golangci-lint run ./... (pinned v2.12.2, matches versions.conf) — 0 issues
  • shellcheck scripts/compile-harness.sh, bash -n, zsh -n — clean
  • jq empty harness/manifest.json — valid
  • bats tests/compile-harness.bats — 44/44 pass (unmodified)
  • bats tests/setup-linux.bats — 64/64 pass (unmodified)
  • Sandboxed HOME=/tmp/fake-home bash scripts/compile-harness.sh --deploy — all 4 instruction files written, second run byte-for-byte idempotent, --check stays green
  • All 5 new checks confirmed live against this machine's real, unmodified state (see specs/HARNESS-070-deploy-convergence/verification.md)

Closes #843. Closes #869. Closes #828. Closes #943.
Refs #802

Archive skip rationale

The spec (specs/HARNESS-070-deploy-convergence/) is left active rather than archived in this PR. This repo's /spec archive step requires an independent adversarial-review pass (review.md with a PASS/PASS-WITH-GAPS verdict, ideally from a different session/agent than the implementer) before archiving — the whole point being that the reviewer is not the same eyes that just wrote the code. That review has not run yet. Rather than self-certify with review: waived or --force-without-review on a change I just wrote myself, the spec stays in specs/ until an independent /adversarial-review pass runs, then archives in a small follow-up PR. All acceptance criteria are met and verified (see verification.md); this is a process-sequencing gap, not unfinished work.

Summary by CodeRabbit

  • New Features

    • Deployment now propagates configured instruction files across supported agent environments.
    • Harness diagnostics detect instruction-file drift and orphaned deployment records.
    • --fix can remove orphaned harness records and converge deployments safely.
    • Unmanaged skill symlinks are ignored during validation.
    • Agent presence configuration now supports source paths and required command checks.
  • Bug Fixes

    • Dotfile version drift is reported as a failure with remediation guidance.
  • Documentation

    • Added deployment-convergence specifications, verification details, and troubleshooting lessons.

Gated on #843 (BUG-058), covering #869 (OPS-025) and #828 (HARNESS-058)
together as one deploy-convergence thread. See proposal.md for the
shared root cause across all three.
Three gaps in dotf doctor's harness-deploy convergence, reproduced live
on this machine:

- checkHarnessMirrorOrphans: a harness/{skills,agents} record deleted
  from the repo survived forever in the ~/.dotfiles mirror (copy-only,
  no prune) and kept failing checkCompileHarnessDrift, which runs FROM
  the mirror. --fix now prunes it, per #802's decided doctor-prunes/
  setup-only-warns semantic. Closes #843 (BUG-058).

- checkOptionalTools: a dotf version behind the versions.conf pin now
  FAILs instead of WARNs -- a stale dotf silently carries none of the
  guards merged since it was built, which is a harder failure than an
  ordinary tool being one version behind. Closes #869 (OPS-025).

- checkDeployedSkillSymlinks: narrowed to only flag a symlink whose
  name has a harness/skills/ record. Investigating the handoff's "4
  symlinked skills = BUG-100 regression" evidence found it was a false
  positive instead -- all four resolve to Orca's own ~/.agents/skills
  mechanism, unrelated to this repo's records, with documented prior
  art for the exclusion in specs/archive/AI-022-pi-harness-slot/. Filed
  and closes #943 (BUG-074).

- checkInstructionDrift (new): reports a deployed instruction file
  (~/.claude/CLAUDE.md and the other three presence targets) that has
  drifted from its repo source, region-stripped so the AGENT-PRESENCE
  and skill-catalog injections don't false-fail the comparison. Part of
  #828 (HARNESS-058) -- the deploy-side half is the next commit.

All four confirmed against real machine state, not just synthetic
fixtures. Go tests only in this PR (tests/*.bats out of scope this
session per multi-session coordination).
Doctrine reached agy/codex (the compact payload) through --deploy but
reached claude/opencode/pi/copilot only through a full setup-linux.sh
run -- so a standalone --deploy left four of six surfaces stale after a
merge. Extends the existing agents.presence[] manifest entries with
source/requires_command fields (they already name the four target
files) and adds deploy_instructions, run first in do_deploy so the
later skill-catalog and AGENT-PRESENCE region injections land on top
of a fresh copy instead of being clobbered by it.

Also fixes a pre-existing bug found while sandbox-testing this:
deploy_agent_presence always logged "[deploy] presence -> ..." as
success even when inject_agent_presence skipped because the target
file was absent, producing two directly contradicting log lines for
the same path in one run. inject_agent_presence now returns non-zero
on that no-op path and the caller only logs success when it actually
wrote something.

Verified with a sandboxed HOME=/tmp/fake-home --deploy run: all four
files written, a second run is a byte-for-byte no-op, and --check
stays green throughout. setup-linux.sh's existing copies are left in
place untouched (now redundant, still correct) -- out of this
session's assigned surface. Windows keeps its own unrelated deploy
path (setup-windows.ps1 sets DotfilesDir = the checkout itself, so it
never had this gap).

Part of #828 (HARNESS-058); the detection-side half
(checkInstructionDrift) was the previous commit.
Two lessons from this session: verifying a "known bug class" label
against the actual symlink targets before building a fix on it (the
BUG-100 evidence turned out to be a false positive, not a regression),
and resolveRepoDir's cwd/git-root fallback silently defeating an
"unresolvable repo" test case run from inside a real checkout.
…nifest sync

checkInstructionDrift compared the copilot instruction file
unconditionally, but deploy_instructions only ever writes it when
`copilot` is on PATH -- on a machine where the file exists (e.g. left
over from before copilot was uninstalled) but the binary is absent,
that was a FAIL no remedy could ever clear, the exact #843 signal-rot
this session exists to kill. deployedInstructionTargets now carries
the same requires_command gate deploy_instructions already applies,
and a new test (TestCheckInstructionDrift_MatchesManifest) parses
harness/manifest.json's agents.presence[] and asserts it stays in
sync with the Go mirror, since that manifest is the actual SSOT the
shell side reads and this Go list only exists because doctor must run
with no repo present.

Also resets features.json's five entries from "state": "passing" back
to "pending" -- the spec template is explicit that only the harness,
not the agent, may write that terminal state; the evidence strings
stay as the record of what was actually run.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds manifest-driven instruction deployment, instruction drift checks, mirror orphan cleanup, managed symlink filtering, and dotf version drift failures. It also adds regression tests and HARNESS-070 deployment-convergence specifications.

Changes

Deployment convergence

Layer / File(s) Summary
Manifest-driven instruction deployment
harness/manifest.json, scripts/compile-harness.sh
The manifest defines instruction sources and command requirements. Deployment copies eligible instruction files before skills and agents, and reports agent-presence injection only when it occurs.
Instruction, mirror, and version drift checks
cli/internal/doctor/checks_deploy.go, cli/internal/doctor/doctor.go, cli/internal/doctor/checks_tools.go, cli/internal/doctor/checks_test.go, cli/internal/doctor/checks_harness_mirror_test.go, cli/internal/doctor/checks_instruction_drift_test.go, docs/lessons.md
Doctor checks compare deployed instruction files after removing harness-generated regions, detect missing or stale files, prune orphan mirror records in fix mode, and report dotf version drift as failure. Tests cover these paths and environment-dependent repository resolution.
Managed deployed symlink validation
cli/internal/doctor/checks_deploy.go, cli/internal/doctor/checks_symlinks_test.go, docs/lessons.md
Symlink checks now report only paths whose names match managed harness skills or commands. Tests cover managed, unmanaged, nested, and empty-path cases.
HARNESS-070 specification and verification
specs/HARNESS-070-deploy-convergence/*
The proposal, task checklist, feature records, and verification record document deployment convergence behavior and validation evidence.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: 🟠 High · up to a89ea

The deployment changes can falsely report drift after a clean deploy, delete an entire mirrored harness when run from the wrong checkout, and report success when an instruction source is missing. These correctness and availability risks should be fixed before merging.

Possibly related issues

Possibly related PRs

Suggested labels: enhancement, debt

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main deploy-convergence changes, including pruning and drift detection across harness surfaces.
Description check ✅ Passed The description provides a detailed summary, scope exclusions, specification references, current-state context, and comprehensive test evidence.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/harness-deploy-convergence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mlorentedev

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts in this pull request

Co-authored-by: mlorentedev <13562150+mlorentedev@users.noreply.github.com>

Copilot AI commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved. The only conflict was in docs/lessons.md — both sides added new lesson entries. Kept all entries from both branches (the three HARNESS-070 lessons from this branch + the dangling-citation lesson from main). Merge commit: a89ea4b.

@mlorentedev mlorentedev added the skip-archive archive-on-merge escape hatch (requires '## Archive skip rationale' in PR body) label Aug 13, 2026
@mlorentedev

Copy link
Copy Markdown
Owner Author

Cross-session note from the branch working on BUG-074 (PR #950), which hit the identical spec-gate failure.

spec-gate is red for archive-on-merge, not for the LOC threshold:

[FAIL] SDD archive-on-merge violation:
       This PR closes an issue whose spec is still active:
         HARNESS-070-deploy-convergence (#843)

Two things that cost me time and may save you some:

  1. A local ./scripts/check-spec-gate.sh passes and proves nothing here. It cannot see the PR body, so it cannot evaluate the closing keywords this rule depends on; CI runs scripts/spec-gate-pr.sh, which can. That false negative is exactly BUG-061: pre-push spec-gate false-negative on archive-on-merge (no PR body locally) #854 (BUG-061), which is in this branch's own scope — so there is now a live reproduction to cite, on two PRs the same day.

  2. dotf spec archive refuses without a fresh, passing review.md from /adversarial-review, and the implementer cannot be the reviewer (the skill forbids single-agent self-review). Sequence: independent review → PASS → dotf spec archive HARNESS-070-deploy-convergence --pr <url> → commit in this PR → gate turns green.

Also, unrelated to the gate: CodeRabbit reports pass here with the description "Review rate limited" — it never read this diff. Four PRs opened in parallel exhausted the free-tier quota (#945 consumed it). Do not read that green as review coverage; evidence recorded on #906.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/internal/doctor/checks_deploy.go`:
- Around line 358-379: Update stripHarnessRegions in
cli/internal/doctor/checks_deploy.go: remove one blank line immediately before
each BEGIN marker and trim trailing newlines before comparison. Update the
deployed fixture in cli/internal/doctor/checks_instruction_drift_test.go lines
98-115 to include the leading newline before the agent-presence BEGIN marker,
reproducing the injector’s deployed bytes.
- Around line 397-429: Update the orphan detection in checkHarnessMirrorOrphans
so each harness subtree is considered only when its corresponding repoDir
exists; skip pruning and reporting for mirror entries when the repo lacks
harness/<sub>. Preserve normal orphan handling when the counterpart directory
exists, and add the requested regression case to TestCheckHarnessMirrorOrphans.

In `@cli/internal/doctor/checks_symlinks_test.go`:
- Around line 15-117: Refactor TestCheckDeployedSkillSymlinks into table-driven
subtests with one case per behavior branch, splitting the combined
managed/unmanaged command case into separate cases. Replace assertions on
rendered prose and exact failure counts with stable report status tags or
structured report results, while continuing to verify managed paths are flagged
and unmanaged paths are ignored.

In `@scripts/compile-harness.sh`:
- Around line 509-519: Track missing instruction sources as a deployment failure
in deploy_agents, and propagate a non-zero status consistent with its existing
missing-record-directory failure path instead of continuing silently. Update
do_deploy to detect that failure and return before printing the success message,
while preserving the existing handling for valid sources and unavailable
requirements.

In `@specs/HARNESS-070-deploy-convergence/proposal.md`:
- Around line 33-34: Add the AC3 failing bats regression test to
specs/HARNESS-070-deploy-convergence/proposal.md:33-34 scope before implementing
the shell --deploy behavior; in
specs/HARNESS-070-deploy-convergence/tasks.md:24-25, schedule that test before
deploy_instructions; update specs/HARNESS-070-deploy-convergence/tasks.md:32
only after the automated test passes so the checklist accurately reflects TDD
completion.
- Line 25: Update the AC3 contract and evidence for the copilot command gate: in
specs/HARNESS-070-deploy-convergence/proposal.md:25,
specs/HARNESS-070-deploy-convergence/features.json:18-21, and
specs/HARNESS-070-deploy-convergence/verification.md:12, state that deployment
skips the Copilot surface when copilot is unavailable, and record tests covering
both unavailable and available copilot paths.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d4a17f7-8768-4467-8bdf-df48fa1c8339

📥 Commits

Reviewing files that changed from the base of the PR and between 3aea746 and a89ea4b.

📒 Files selected for processing (14)
  • cli/internal/doctor/checks_deploy.go
  • cli/internal/doctor/checks_harness_mirror_test.go
  • cli/internal/doctor/checks_instruction_drift_test.go
  • cli/internal/doctor/checks_symlinks_test.go
  • cli/internal/doctor/checks_test.go
  • cli/internal/doctor/checks_tools.go
  • cli/internal/doctor/doctor.go
  • docs/lessons.md
  • harness/manifest.json
  • scripts/compile-harness.sh
  • specs/HARNESS-070-deploy-convergence/features.json
  • specs/HARNESS-070-deploy-convergence/proposal.md
  • specs/HARNESS-070-deploy-convergence/tasks.md
  • specs/HARNESS-070-deploy-convergence/verification.md

Comment on lines +358 to +379
func stripHarnessRegions(content string) string {
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
skip, endMarker := false, ""
for _, l := range lines {
if skip {
if l == endMarker {
skip = false
}
continue
}
switch {
case strings.HasPrefix(l, harnessBeginPrefix):
skip, endMarker = true, harnessEndMarker
case strings.HasPrefix(l, agentPresenceBeginPrefix):
skip, endMarker = true, agentPresenceEndMarker
default:
out = append(out, l)
}
}
return strings.Join(out, "\n")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Region stripping and its test fixture both ignore the blank line the injector writes. inject_agent_presence in scripts/compile-harness.sh line 802 appends a region as "\n" + BEGIN + body + END + "\n". deploy_instructions copies the source verbatim first, so the append branch always runs and the deployed file always gains that blank separator. Neither the comparison nor the test models it, so checkInstructionDrift reports drift for all four targets after a clean --deploy while the test suite stays green.

  • cli/internal/doctor/checks_deploy.go#L358-L379: drop a single blank line that immediately precedes a BEGIN marker, and trim trailing newlines before comparing.
  • cli/internal/doctor/checks_instruction_drift_test.go#L98-L115: build the deployed fixture with the leading "\n" before <!-- BEGIN HARNESS AGENT-PRESENCE, so the subtest reproduces the deployed bytes.
📍 Affects 2 files
  • cli/internal/doctor/checks_deploy.go#L358-L379 (this comment)
  • cli/internal/doctor/checks_instruction_drift_test.go#L98-L115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/internal/doctor/checks_deploy.go` around lines 358 - 379, Update
stripHarnessRegions in cli/internal/doctor/checks_deploy.go: remove one blank
line immediately before each BEGIN marker and trim trailing newlines before
comparison. Update the deployed fixture in
cli/internal/doctor/checks_instruction_drift_test.go lines 98-115 to include the
leading newline before the agent-presence BEGIN marker, reproducing the
injector’s deployed bytes.

Comment on lines +397 to +429
repo := resolveRepoDir(sys)
if repo == "" || filepath.Clean(repo) == filepath.Clean(cfg.DotfilesDir) {
return // no checkout found, or the "mirror" IS the checkout — nothing to compare
}

orphans := 0
for _, sub := range []string{"skills", "agents"} {
mirrorDir := filepath.Join(cfg.DotfilesDir, "harness", sub)
if !isDir(mirrorDir) {
continue
}
entries, err := os.ReadDir(mirrorDir)
if err != nil {
continue
}
repoDir := filepath.Join(repo, "harness", sub)
for _, e := range entries {
if !e.IsDir() || isDir(filepath.Join(repoDir, e.Name())) {
continue
}
orphans++
rel := filepath.Join("harness", sub, e.Name())
target := filepath.Join(mirrorDir, e.Name())
if !fix {
rep.Fail("orphan mirror record: " + rel + " (no repo counterpart — run: dotf doctor --fix)")
continue
}
if err := os.RemoveAll(target); err != nil {
rep.Fail("failed to prune orphan mirror record: " + rel + " (" + err.Error() + ")")
} else {
rep.Fix("pruned orphan mirror record: " + rel)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

--fix deletes every mirror record when the resolved repo lacks harness/<sub>.

resolveRepoDir falls back to the current working directory and walks up for a .git directory. docs/lessons.md line 2254 records this behavior. If a user runs dotf doctor --fix from an unrelated git checkout, repo resolves to that checkout. repoDir then does not exist, isDir(filepath.Join(repoDir, e.Name())) is false for every entry, and line 424 removes every record under <mirror>/harness/skills and <mirror>/harness/agents.

The records are regenerable by re-running setup, but the deployed harness breaks until then, and the operation is not reversible from the doctor output alone.

Require the repo counterpart directory to exist before you treat mirror entries as orphans.

🛡️ Proposed guard
 		repoDir := filepath.Join(repo, "harness", sub)
+		if !isDir(repoDir) {
+			// The resolved checkout has no counterpart tree at all (wrong repo,
+			// partial clone). Every entry would look orphaned — refuse to prune.
+			rep.Skip("repo has no " + filepath.Join("harness", sub) + " — orphan comparison skipped")
+			continue
+		}
 		for _, e := range entries {

Add the matching table case to TestCheckHarnessMirrorOrphans:

t.Run("repo without harness/skills must not prune the mirror", func(t *testing.T) {
	repo := t.TempDir() // no harness/ tree at all
	mirror := t.TempDir()
	mkdirAll(t, filepath.Join(mirror, "harness", "skills", "kept"))
	cfg := &Config{DotfilesDir: mirror}
	sys := newSys(map[string]string{"DOTFILES_REPO_DIR": repo}, nil, nil)

	var buf bytes.Buffer
	checkHarnessMirrorOrphans(sys, cfg, capture(&buf), true)

	if !isDir(filepath.Join(mirror, "harness", "skills", "kept")) {
		t.Error("must not prune when the repo has no counterpart tree")
	}
})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
repo := resolveRepoDir(sys)
if repo == "" || filepath.Clean(repo) == filepath.Clean(cfg.DotfilesDir) {
return // no checkout found, or the "mirror" IS the checkout — nothing to compare
}
orphans := 0
for _, sub := range []string{"skills", "agents"} {
mirrorDir := filepath.Join(cfg.DotfilesDir, "harness", sub)
if !isDir(mirrorDir) {
continue
}
entries, err := os.ReadDir(mirrorDir)
if err != nil {
continue
}
repoDir := filepath.Join(repo, "harness", sub)
for _, e := range entries {
if !e.IsDir() || isDir(filepath.Join(repoDir, e.Name())) {
continue
}
orphans++
rel := filepath.Join("harness", sub, e.Name())
target := filepath.Join(mirrorDir, e.Name())
if !fix {
rep.Fail("orphan mirror record: " + rel + " (no repo counterpart — run: dotf doctor --fix)")
continue
}
if err := os.RemoveAll(target); err != nil {
rep.Fail("failed to prune orphan mirror record: " + rel + " (" + err.Error() + ")")
} else {
rep.Fix("pruned orphan mirror record: " + rel)
}
}
repo := resolveRepoDir(sys)
if repo == "" || filepath.Clean(repo) == filepath.Clean(cfg.DotfilesDir) {
return // no checkout found, or the "mirror" IS the checkout — nothing to compare
}
orphans := 0
for _, sub := range []string{"skills", "agents"} {
mirrorDir := filepath.Join(cfg.DotfilesDir, "harness", sub)
if !isDir(mirrorDir) {
continue
}
entries, err := os.ReadDir(mirrorDir)
if err != nil {
continue
}
repoDir := filepath.Join(repo, "harness", sub)
if !isDir(repoDir) {
// The resolved checkout has no counterpart tree at all (wrong repo,
// partial clone). Every entry would look orphaned — refuse to prune.
rep.Skip("repo has no " + filepath.Join("harness", sub) + " — orphan comparison skipped")
continue
}
for _, e := range entries {
if !e.IsDir() || isDir(filepath.Join(repoDir, e.Name())) {
continue
}
orphans++
rel := filepath.Join("harness", sub, e.Name())
target := filepath.Join(mirrorDir, e.Name())
if !fix {
rep.Fail("orphan mirror record: " + rel + " (no repo counterpart — run: dotf doctor --fix)")
continue
}
if err := os.RemoveAll(target); err != nil {
rep.Fail("failed to prune orphan mirror record: " + rel + " (" + err.Error() + ")")
} else {
rep.Fix("pruned orphan mirror record: " + rel)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/internal/doctor/checks_deploy.go` around lines 397 - 429, Update the
orphan detection in checkHarnessMirrorOrphans so each harness subtree is
considered only when its corresponding repoDir exists; skip pruning and
reporting for mirror entries when the repo lacks harness/<sub>. Preserve normal
orphan handling when the counterpart directory exists, and add the requested
regression case to TestCheckHarnessMirrorOrphans.

Comment on lines +15 to +117
func TestCheckDeployedSkillSymlinks(t *testing.T) {
newEnv := func(t *testing.T) (home, mirror string, cfg *Config, sys *System) {
t.Helper()
home = t.TempDir()
mirror = t.TempDir()
mkdirAll(t, filepath.Join(mirror, "harness", "skills", "orca-cli"))
cfg = &Config{DotfilesDir: mirror}
sys = newSys(map[string]string{"HOME": home, "DOTFILES_REPO_DIR": mirror}, nil, nil)
return
}

t.Run("symlinked dir at an unmanaged name is silent", func(t *testing.T) {
home, _, cfg, sys := newEnv(t)
foreignTarget := t.TempDir()
mustSymlink(t, foreignTarget, filepath.Join(home, ".claude", "skills", "computer-use"))

var buf bytes.Buffer
rep := capture(&buf)
checkDeployedSkillSymlinks(sys, cfg, rep)

if rep.Failures() != 0 {
t.Fatalf("unmanaged-name symlink must not fail\n%s", buf.String())
}
if !strings.Contains(buf.String(), "no symlinks at managed skill names") {
t.Errorf("expected the narrowed pass line\n%s", buf.String())
}
})

t.Run("symlinked dir at a managed name fails and names the path", func(t *testing.T) {
home, _, cfg, sys := newEnv(t)
foreignTarget := t.TempDir()
managedPath := filepath.Join(home, ".claude", "skills", "orca-cli")
mustSymlink(t, foreignTarget, managedPath)

var buf bytes.Buffer
rep := capture(&buf)
checkDeployedSkillSymlinks(sys, cfg, rep)

if rep.Failures() == 0 {
t.Fatalf("managed-name symlink must fail\n%s", buf.String())
}
if !strings.Contains(buf.String(), managedPath) {
t.Errorf("expected the flagged path in output\n%s", buf.String())
}
})

t.Run("symlinked SKILL.md one level inside a managed dir fails", func(t *testing.T) {
home, _, cfg, sys := newEnv(t)
realDir := filepath.Join(home, ".claude", "skills", "orca-cli")
mkdirAll(t, realDir)
foreignFile := filepath.Join(t.TempDir(), "SKILL.md")
if err := os.WriteFile(foreignFile, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
mustSymlink(t, foreignFile, filepath.Join(realDir, "SKILL.md"))

var buf bytes.Buffer
rep := capture(&buf)
checkDeployedSkillSymlinks(sys, cfg, rep)

if rep.Failures() == 0 {
t.Fatalf("symlinked SKILL.md at a managed name must fail\n%s", buf.String())
}
})

t.Run("symlinked command file at an unmanaged name is silent, managed one fails", func(t *testing.T) {
home, _, cfg, sys := newEnv(t)
foreignFile := filepath.Join(t.TempDir(), "src.md")
mustSymlink(t, foreignFile, filepath.Join(home, ".config", "opencode", "commands", "find-skills.md"))
mustSymlink(t, foreignFile, filepath.Join(home, ".config", "opencode", "commands", "orca-cli.md"))

var buf bytes.Buffer
rep := capture(&buf)
checkDeployedSkillSymlinks(sys, cfg, rep)

// header line + the one flagged path (findSymlinks' header counts as a
// Fail too, same as the pre-existing behavior this test guards).
if rep.Failures() != 2 {
t.Fatalf("expected 2 failures (header + orca-cli.md only), got %d\n%s", rep.Failures(), buf.String())
}
if strings.Contains(buf.String(), "find-skills.md") {
t.Errorf("unmanaged command symlink must not appear in output\n%s", buf.String())
}
if !strings.Contains(buf.String(), "orca-cli.md") {
t.Errorf("expected managed command symlink in output\n%s", buf.String())
}
})

t.Run("no deployed skill paths -> skip", func(t *testing.T) {
home := t.TempDir()
mirror := t.TempDir()
cfg := &Config{DotfilesDir: mirror}
sys := newSys(map[string]string{"HOME": home}, nil, nil)

var buf bytes.Buffer
rep := capture(&buf)
checkDeployedSkillSymlinks(sys, cfg, rep)

if rep.Failures() != 0 || !strings.Contains(buf.String(), "no deployed skill paths found") {
t.Errorf("expected a skip\n%s", buf.String())
}
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use table cases and stable report status tags.

Lines 26-115 use separate closures and match rendered diagnostic prose. Line 80 also tests two branches in one case. Convert this suite to table-driven cases with one case per branch. Assert report status tags or structured results instead of strings such as "no symlinks at managed skill names".

As per coding guidelines: “Use table-driven tests with one test case per branch, and assert stable status tags rather than prose.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/internal/doctor/checks_symlinks_test.go` around lines 15 - 117, Refactor
TestCheckDeployedSkillSymlinks into table-driven subtests with one case per
behavior branch, splitting the combined managed/unmanaged command case into
separate cases. Replace assertions on rendered prose and exact failure counts
with stable report status tags or structured report results, while continuing to
verify managed paths are flagged and unmanaged paths are ignored.

Source: Coding guidelines

Comment on lines +509 to +519
local agent file source requires dest
while IFS=$'\t' read -r agent file source requires; do
[[ -n "$source" ]] || continue
if [[ -n "$requires" ]] && ! command -v "$requires" >/dev/null 2>&1; then
printf '[deploy] instructions target %s skipped: %s not on PATH\n' "$agent" "$requires"
continue
fi
if [[ ! -f "$REPO_ROOT/$source" ]]; then
printf '[ERROR] instruction source missing: %s\n' "$REPO_ROOT/$source" >&2
continue
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A missing instruction source is reported but never fails the deploy.

Line 517 writes [ERROR] instruction source missing to stderr, then continue. do_deploy still reaches line 496 and prints [deploy] OK, and the exit status stays 0. The output therefore contains an [ERROR] line followed by a success line — the same contradiction this PR removes at lines 818-825.

The doctor side does not cover the gap either: checkInstructionDrift skips a target when the source path does not exist (!pathExists(source)continue in cli/internal/doctor/checks_deploy.go). A mistyped source in harness/manifest.json is then invisible in both surfaces after that single stderr line.

Track the failure and propagate it. deploy_agents already exits 2 for a missing record directory, so a non-zero exit is consistent with the file.

🛠️ Proposed fix to propagate the missing-source failure
 deploy_instructions() {
-    local agent file source requires dest
+    local agent file source requires dest rc=0
     while IFS=$'\t' read -r agent file source requires; do
         [[ -n "$source" ]] || continue
         if [[ -n "$requires" ]] && ! command -v "$requires" >/dev/null 2>&1; then
             printf '[deploy] instructions target %s skipped: %s not on PATH\n' "$agent" "$requires"
             continue
         fi
         if [[ ! -f "$REPO_ROOT/$source" ]]; then
             printf '[ERROR] instruction source missing: %s\n' "$REPO_ROOT/$source" >&2
+            rc=1
             continue
         fi
         dest="$HOME/$file"
         [[ -L "$dest" ]] && rm -f "$dest"
         mkdir -p "$(dirname "$dest")"
         cp -f "$REPO_ROOT/$source" "$dest"
         printf '[deploy] instructions -> %s\n' "$dest"
     done < <(jq -r '.agents.presence[] | "\(.agent)\t\(.file)\t\(.source // "")\t\(.requires_command // "")"' "$MANIFEST")
+    return "$rc"
 }

do_deploy then needs to stop instead of printing OK:

     if jq -e '.agents.presence' "$MANIFEST" >/dev/null 2>&1; then
-        deploy_instructions
+        deploy_instructions || exit 2
     fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
local agent file source requires dest
while IFS=$'\t' read -r agent file source requires; do
[[ -n "$source" ]] || continue
if [[ -n "$requires" ]] && ! command -v "$requires" >/dev/null 2>&1; then
printf '[deploy] instructions target %s skipped: %s not on PATH\n' "$agent" "$requires"
continue
fi
if [[ ! -f "$REPO_ROOT/$source" ]]; then
printf '[ERROR] instruction source missing: %s\n' "$REPO_ROOT/$source" >&2
continue
fi
local agent file source requires dest rc=0
while IFS=$'\t' read -r agent file source requires; do
[[ -n "$source" ]] || continue
if [[ -n "$requires" ]] && ! command -v "$requires" >/dev/null 2>&1; then
printf '[deploy] instructions target %s skipped: %s not on PATH\n' "$agent" "$requires"
continue
fi
if [[ ! -f "$REPO_ROOT/$source" ]]; then
printf '[ERROR] instruction source missing: %s\n' "$REPO_ROOT/$source" >&2
rc=1
continue
fi
Suggested change
local agent file source requires dest
while IFS=$'\t' read -r agent file source requires; do
[[ -n "$source" ]] || continue
if [[ -n "$requires" ]] && ! command -v "$requires" >/dev/null 2>&1; then
printf '[deploy] instructions target %s skipped: %s not on PATH\n' "$agent" "$requires"
continue
fi
if [[ ! -f "$REPO_ROOT/$source" ]]; then
printf '[ERROR] instruction source missing: %s\n' "$REPO_ROOT/$source" >&2
continue
fi
deploy_instructions || exit 2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/compile-harness.sh` around lines 509 - 519, Track missing instruction
sources as a deployment failure in deploy_agents, and propagate a non-zero
status consistent with its existing missing-record-directory failure path
instead of continuing silently. Update do_deploy to detect that failure and
return before printing the success message, while preserving the existing
handling for valid sources and unavailable requirements.


1. `dotf doctor` detects orphan records under `harness/{skills,agents}` in the deploy mirror (present in `$DOTFILES_DIR`, absent from the repo) and `dotf doctor --fix` prunes them.
2. `dotf doctor` FAILs (not WARNs) when the installed `dotf` version differs from the `versions.conf` pin — a stale `dotf` means whatever guards shipped after it was built are not running at all, which is a harder failure than an ordinary tool being one version behind.
3. `compile-harness.sh --deploy` copies the full doctrine/instruction files (`ai/claude/CLAUDE.md`, `AGENTS.md`, `ai/copilot/copilot-instructions.md`) to their per-agent `$HOME` paths itself, so a standalone `--deploy` run — without a full `setup-linux.sh` pass — brings all six surfaces (agy, codex, claude, opencode, pi, copilot) current in one command.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update the AC3 contract and evidence for the copilot command gate.

When copilot is unavailable, the deployment skips the Copilot surface. State this condition in proposal.md, features.json, and verification.md. Record tests for both unavailable and available copilot paths.

📍 Affects 3 files
  • specs/HARNESS-070-deploy-convergence/proposal.md#L25-L25 (this comment)
  • specs/HARNESS-070-deploy-convergence/features.json#L18-L21
  • specs/HARNESS-070-deploy-convergence/verification.md#L12-L12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/HARNESS-070-deploy-convergence/proposal.md` at line 25, Update the AC3
contract and evidence for the copilot command gate: in
specs/HARNESS-070-deploy-convergence/proposal.md:25,
specs/HARNESS-070-deploy-convergence/features.json:18-21, and
specs/HARNESS-070-deploy-convergence/verification.md:12, state that deployment
skips the Copilot surface when copilot is unavailable, and record tests covering
both unavailable and available copilot paths.

Comment on lines +33 to +34
- `DOTFILES_AUTODEPLOY` disposition (#869 remedy (a)) — a per-machine judgment call for the user, not a code change. Surfaced as an open decision in the PR body.
- bats coverage for the shell-side `--deploy` change (`tests/*.bats` is out of scope for this session per explicit multi-session coordination) — the Go-side doctor checks get Go tests; the shell change is verified manually and by the existing `--check` gate, with a bats follow-up proposed, not filed, in the PR body.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add the AC3 regression test before closing the spec.

The proposal defers bats coverage, the task plan records manual-only verification, and the closing checklist claims every acceptance criterion has a test. These statements conflict with the repository TDD requirement.

  • specs/HARNESS-070-deploy-convergence/proposal.md#L33-L34: add the failing bats test to the scope before implementing the shell behavior.
  • specs/HARNESS-070-deploy-convergence/tasks.md#L24-L25: place the failing test before the deploy_instructions implementation.
  • specs/HARNESS-070-deploy-convergence/tasks.md#L32-L32: update the checklist only after the automated test passes.

As per coding guidelines, the repository requires “TDD — failing test first, then the fix.”

📍 Affects 2 files
  • specs/HARNESS-070-deploy-convergence/proposal.md#L33-L34 (this comment)
  • specs/HARNESS-070-deploy-convergence/tasks.md#L24-L25
  • specs/HARNESS-070-deploy-convergence/tasks.md#L32-L32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/HARNESS-070-deploy-convergence/proposal.md` around lines 33 - 34, Add
the AC3 failing bats regression test to
specs/HARNESS-070-deploy-convergence/proposal.md:33-34 scope before implementing
the shell --deploy behavior; in
specs/HARNESS-070-deploy-convergence/tasks.md:24-25, schedule that test before
deploy_instructions; update specs/HARNESS-070-deploy-convergence/tasks.md:32
only after the automated test passes so the checklist accurately reflects TDD
completion.

Source: Coding guidelines

@mlorentedev
mlorentedev merged commit 18ccd60 into main Aug 13, 2026
13 of 17 checks passed
@mlorentedev
mlorentedev deleted the fix/harness-deploy-convergence branch August 13, 2026 03:28
@mlorentedev

Copy link
Copy Markdown
Owner Author

This PR was squash-merged (18ccd60) before the CodeRabbit review-fix commit on this branch landed — that commit was pushed ~21h after this PR closed, to an already-merged branch, so it never got CI and never merged. It carried 3 confirmed Major findings from CodeRabbit's review here (blank-line false-positive in checkInstructionDrift, a data-loss guard for checkHarnessMirrorOrphans --fix, and an exit-code propagation fix in deploy_instructions).

Landed via follow-up: #954

mlorentedev added a commit that referenced this pull request Aug 14, 2026
…rge (#954)

* fix(review): address CodeRabbit findings on PR #948

Six findings, each verified against actual behavior before acting:

- stripHarnessRegions didn't drop the blank separator line
  inject_agent_presence/replace_region's append branch always writes
  before a region. Reproduced empirically with a real sandboxed
  --deploy run: a genuinely clean deploy produced 3 false FAILs in
  checkInstructionDrift. Fixed and re-verified clean. Most severe
  finding -- the check was actively broken for its stated purpose.

- checkHarnessMirrorOrphans --fix could delete an entire harness/<sub>
  tree in the mirror if resolveRepoDir ever resolved to a checkout
  lacking that subtree (unrelated repo, DOTFILES_REPO_DIR unset) --
  every mirror entry would read as orphaned. Added a guard: skip the
  whole subtree comparison when the repo counterpart directory is
  absent, rather than treating that as "everything is orphaned."

- deploy_instructions' missing-source case printed [ERROR] but let
  do_deploy still print [deploy] OK and exit 0 -- the same
  contradicting-log-lines shape as the deploy_agent_presence bug fixed
  earlier this PR. Now propagates a non-zero exit, matching
  deploy_agents' existing convention for a missing record directory.

- AC3's copilot-gate condition wasn't stated in the spec's "What"
  section (only in the AC itself) and had no test coverage for the
  copilot-PRESENT path. Added Go tests for both paths (verified against
  a real sandboxed deploy with a PATH-stubbed copilot binary first) and
  tightened the spec wording.

- tasks.md's closing checklist overstated test coverage ("every AC
  covered by at least one test") when AC3 is manual-only, no bats.
  Reworded to be precise about automated vs. manual verification.

- Declined: table-driven-tests-with-status-tags suggestion for
  checks_symlinks_test.go. This package's own established convention
  (checks_deploy_drift_test.go) asserts on prose substrings throughout,
  so the suggestion would make this one file inconsistent with its
  neighbors. Recorded, not silently ignored.

Full details in specs/HARNESS-070-deploy-convergence/verification.md.

* docs(lessons): record the merged-PR-vs-API-lag misdiagnosis

PR #948 was squash-merged before this branch's CodeRabbit review-fix
commit landed, so the fix commit never got CI and never merged. Record
the diagnostic lesson (check state/merged before diagnosing head.sha
mismatch as lag) alongside the follow-up fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-archive archive-on-merge escape hatch (requires '## Archive skip rationale' in PR body)

Projects

None yet

2 participants