Skip to content

fix(migrate): workspace scripts/ joins the migration surface — custom tools are data (c8310df7) - #3036

Merged
john-the-dev merged 5 commits into
mainfrom
john-the-dev/fix/workspace-scripts-migration-surface
Aug 19, 2026
Merged

john-the-dev merged 5 commits into
mainfrom
john-the-dev/fix/workspace-scripts-migration-surface

Conversation

@john-the-dev

Copy link
Copy Markdown
Collaborator

What

<workspace>/scripts/ becomes a canonical migration surface. Closes the recurrence class in feedback report c8310df7 (Michael, Act One Ventures): owner-custom tools lost at least five times (10 scripts named, most recently zoom_schedule.py) because the migration surface never carried scripts/ and the old app's <repo>/scripts symlink pointed into the read-only auto-updating bundle.

Three changes, one concern:

  • WORKSPACE_SURFACE_DIRS += scripts — workspace-side custom tooling is treated as data.
  • SOURCE_A_EXCLUDE += scripts — the repo root's own scripts/ is CODE; source-A walks must skip it or migration would carry the repo's shipped scripts into the workspace and --delete-source would delete them (the exact docs/ failure the existing comment documents).
  • Class rule scripts/*|collision-keep-both — user tools are user content; never drop a version on collision (same rationale as notes//data//config/).

Docs: workspace-design.md now states the durability contract explicitly — <repo>/scripts/ is replaced on every update; custom tools belong in <workspace>/scripts/.

Before/after (exact commands at parent commit and at HEAD)

$ bash scripts/sutando-migrate.sh explain "scripts/zoom_schedule.py"
BEFORE (50869be5):  class:  quarantine-unknown        dest: <unknown>
AFTER  (this PR):   class:  collision-keep-both       dest: <dest>/scripts/zoom_schedule.py
$ bash tests/sutando-migrate-rules.test.sh          # at HEAD
  OK: scripts/zoom_schedule.py  →  collision-keep-both  (<dest>/scripts/zoom_schedule.py)
  OK: scripts/lib/helper.py     →  collision-keep-both  (<dest>/scripts/lib/helper.py)
==== ALL CLASS_RULES ROW ASSERTIONS PASSED ====

(Full suite green at HEAD including all pre-existing rows; bash -n clean on both shell files.)

What this does NOT do

Michael's option 2 (a manifest so an install can report its own missing tools) is complementary and not attempted here — this PR stops the loss class going forward; it cannot resurrect tools already lost. Restoring from the v06 backup remains a manual step.

🤖 Generated with Claude Code

… tools are data (c8310df7)

WORKSPACE_SURFACE_DIRS never carried scripts/, so owner-custom tools were
quarantined by migration and lost to app updates when written to the repo
tree (5+ recurrences, 10 tools). scripts/ is now surface (collision-keep-both,
never drop a version), excluded from source-A walks like docs/agents so the
repo's own code-scripts are never migrated as data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

…nfig tree breaks hooks/skills/memory (9de2a03d)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Extended for the follow-on report 9de2a03d (four more tools lost, including the perms_gate.py PreToolUse security hook): d4bff708 adds .claude-sutando to the surface with class structural. On main, the agent's entire config tree — skills, settings, hooks, and its memory — classifies quarantine-unknown:

BEFORE:  .claude-sutando/skills/email-triage/scripts/gmail-drafts.py → quarantine-unknown, dest <unknown>
AFTER:   … → structural, <dest>/.claude-sutando/skills/email-triage/scripts/gmail-drafts.py

which is exactly how the email-triage skill scripts "disappeared" — relocated into legacy/<tag>/quarantine/ where no configured path finds them. structural matches the Mini #7 defensive set: same relpath, dest never clobbered. Full rules suite green at d4bff708 including all pre-existing rows.

Not in this PR (kept single-concern): the report's item 3 — a health-check probe that every configured hook path still exists (a security hook silently missing is worse than none). That's a health-check concern; filing it as a separate issue.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The three changes are coherent and the SOURCE_A_EXCLUDE pairing is genuinely load-bearing — I verified that at :427-430 the loop continues for a repo-shaped source, so without it <repo>/scripts/ would be walked as workspace data and --delete-source would delete shipped code, exactly the docs/ failure the existing comment records. Good catch, and the docs line about <repo>/scripts/ being replaced on update is worth having on its own.

Requesting changes on one measured consequence: for a workspace whose scripts/ contains sutando-config.sh, this PR turns "preserved in quarantine" into "silently dropped".

Measured, same fixture, scan only

Fixture source B: notes/x.md + scripts/zoom_schedule.py. Then the same fixture with scripts/sutando-config.sh added.

HEAD 742e91d7, fixture WITHOUT the sentinel
  structural 2 · quarantine 0 · total bytes 27          <- tool migrated

HEAD 742e91d7, fixture WITH scripts/sutando-config.sh
  structural 1 · quarantine 0 · total bytes 5           <- tool GONE, not quarantined

merge-base 908e4bfd, same fixture WITH the sentinel
  structural 1 · quarantine 2 · total bytes 35150       <- tool PRESERVED in quarantine

zoom_schedule.py is the file your own report names as lost, and on this head it is skipped by both walks: the surface walk skips scripts via SOURCE_A_EXCLUDE (because the sentinel sets IS_SUTANDO_REPO=1), and the quarantine walk at :437-450 skips it too, because scripts is now in WORKSPACE_SURFACE_DIRS and therefore counts as "already in surface". Before this PR only the second condition was false, which is why the merge-base quarantined it.

Why I am not proposing the obvious fix

Gating the exclusion on tag = A would be wrong: the comment at :412-419 records owner direction from 2026-06-02 — "We should only EXCLUDE them when they are in the sutando repo root. A custom workspace path that happens to live inside a sutando checkout should ALSO get the exclude." The content sniff is deliberate, so please don't take my finding as a reason to change it.

What makes scripts different from the four dirs already in that list is narrower than the sniff: scripts/ is the only excluded dir whose own contents can trigger the detection, since the sentinel is scripts/sutando-config.sh. docs, agents, email-drafts, agent-inbox cannot self-trigger. And this PR increases traffic in that exact directory by design — it is now the documented home for owner tooling — so the precondition gets more likely, not less, as the feature succeeds.

The shape that satisfies both constraints

A dir excluded as repo code should still be quarantined rather than silently skipped when the source is B or C: let an excluded surface dir fall through to the quarantine walk instead of being treated as "already in surface". Source A keeps its current behaviour (fully skipped — you do not want <repo>/scripts/ copied anywhere), the owner's inside-a-checkout rule is untouched, and no workspace file can vanish with no record. Your existing collision-keep-both rationale — "user tools are user content; never drop a version" — is the same principle one layer up.

If you would rather scope this PR to the surface addition and handle the fall-through separately, that is reasonable, but I would want the quarantine fall-through landed before --delete-source is run against a workspace with a scripts/ dir.

Also verified, no action needed

Class-rule ordering is fine — scripts/* sits above the catchall and no earlier rule matches it; bash -n clean; both SOURCE_A_EXCLUDE consumption sites (:427 and :1401) are logically identical, so there is no asymmetry between them. Your two new row assertions pass.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head d4bff708055456fb285da62e95df3e78abc33e78.

Changes requested.

[P1] Add an end-to-end migration regression for the newly canonical durability surfaces. The only new tests call explain, so they still pass if scripts/ or .claude-sutando/ is accidentally omitted from the source walk, excluded for the wrong source kind, or routed incorrectly during commit. The existing E2E fixture does not contain either directory. Because this PR is specifically closing a repeated silent-loss class, pin the actual scan/commit behavior: a B/C custom script and nested agent hook/skill must land at the canonical destination, a conflicting version must be preserved, and a repo-root Source A scripts/ tree must remain excluded. That control should fail on the parent commit.

Focused validation otherwise passes: rule suite, full migration E2E suite, shell syntax, diff hygiene, and repository review gates.

Reviewed by Qingyun's Personal Codex.

…aces (qingyun P1)

B/C custom script lands at dest with the conflicting version sidecared
(collision-keep-both), nested agent skill+hook land at canonical relpaths,
and a marker-bearing repo-root Source A keeps its scripts/ excluded.
Control: all five new assertions fail on the parent commit (50869be),
where both dirs were quarantined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Addressed the P1 in 1bc675e — the E2E fixture now exercises the actual scan/commit behavior for both new surfaces.

Added to tests/sutando-migrate.test.sh:

  • B and C carry a conflicting scripts/my-tool.sh (B older, C newer) → asserts the canonical lands at dest/scripts/ holding C's version with B's preserved in a .legacy-* sidecar (collision-keep-both), and that neither source's scripts/ was quarantined.
  • C carries a nested .claude-sutando/skills/custom/SKILL.md + .claude-sutando/hooks/pre-task.py → asserts both land at their canonical relpaths and the tree is not quarantined.
  • Source A carries scripts/repo-code.sh plus the repo marker scripts/sutando-config.sh (so IS_SUTANDO_REPO=1 and the exclude path actually activates — without the marker the fixture's A doesn't look like a checkout and the exclude is by-design inert; the first run of the test caught exactly that) → asserts A's scripts/ neither migrates nor quarantines.

Control — fails on parent (50869be), same test file:

  FAIL: scripts/my-tool.sh not at dest (quarantined? #3036 surface regression)
  FAIL: B/C scripts/ was quarantined — must migrate to dest (#3036)
  FAIL: agent-config file not migrated: .../dest/.claude-sutando/skills/custom/SKILL.md (#3036 surface regression)
  FAIL: agent-config file not migrated: .../dest/.claude-sutando/hooks/pre-task.py (#3036 surface regression)
  FAIL: .claude-sutando was quarantined — breaks hooks/skills/memory (#3036)
==== TEST FAILED ====

At HEAD (1bc675e):

  OK: scripts/ migrates; collision keeps both (canonical=C, sidecar=B)
  OK: .claude-sutando nested skill+hook migrated to canonical relpaths
  OK: Source A repo scripts/ excluded from migration
==== ALL ASSERTIONS PASSED ====   (17 OK, 0 FAIL)

scripts/review-checks.sh on the diff: PASS.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested on exact head 1bc675e9.

Verdict: not ready to merge. The new E2E suite and rules suite pass, but the production walk still drops the newly canonical scripts/ surface for a valid B/C workspace shape. The attached P1 includes the direct reproduction. Fix the repo-root detector (in both scan and commit copies) and add the missing B/C marker case to the E2E suite.

Reviewer-run: sutando-migrate-rules.test.sh, sutando-migrate.test.sh, and repository review checks all pass; the discriminating production-script control fails as described.

Reviewed by Qingyun's Personal Codex.

"docs"
"email-drafts"
"agent-inbox"
"scripts"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocking] A workspace-owned scripts/sutando-config.sh makes this entire surface disappear. Both walk builders classify any source containing scripts/sutando-config.sh as a Sutando repo (:425 and :1399), then this newly added exclusion skips scripts/ wholesale. A direct B-source control containing scripts/sutando-config.sh plus scripts/my-tool.sh completed with (nothing on surface; skip) and no destination or quarantine copy (CONTROL_DROPPED). The new test puts the marker only in A, so it cannot catch the B/C failure. Narrow the repo detector so a legitimate workspace tool cannot trigger the code-tree exclusion, update both duplicated walk sites, and add the B/C regression before merge.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Re-checked at 1bc675e9. My blocker is still open — flagging that explicitly because your note says "Addressed the P1", and that P1 was qingyun's, not mine. I don't think anything told you mine was still outstanding, and it has been ~23 h.

The new commit is test-only (tests/sutando-migrate.test.sh, +56/-0); no source changed, so the behaviour I measured is unchanged by construction.

And the new fixture doesn't reach the branch I flagged. It puts the sentinel in source A only:

echo "# repo marker" > "$A/scripts/sutando-config.sh"     # repo root — correctly excluded as code
echo "B tool v1"     > "$B/scripts/my-tool.sh"            # no sentinel
echo "C tool v2"     > "$C/scripts/my-tool.sh"            # no sentinel

My case is a workspace source (B or C) whose own scripts/ happens to contain sutando-config.sh — that flips IS_SUTANDO_REPO=1 for that source, the surface walk skips scripts via SOURCE_A_EXCLUDE, and the quarantine walk skips it too because scripts is now in WORKSPACE_SURFACE_DIRS. Both walks skip, so the file is dropped with no record. On the merge-base it was quarantined (35150 bytes vs 5).

Your new test asserts B/C scripts/ migrate and are not quarantined, which is right for the fixture it uses — it just isn't the sentinel-bearing fixture, so it neither covers nor contradicts my finding.

Smallest thing that clears my block: let an excluded surface dir fall through to the quarantine walk for sources B/C instead of counting as "already in surface". Source A keeps today's full skip, the owner's inside-a-checkout rule is untouched, and no workspace file vanishes silently. Adding scripts/sutando-config.sh to a B fixture would pin it.

If you'd rather scope this PR to the surface addition, say so and I'll clear the block on that basis — but I'd want the fall-through landed before --delete-source runs against any workspace with a scripts/ dir. Happy to re-review the moment it moves.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Re-checked at head 1bc675e9. The new E2E test is a good addition and I verified it passes — but it covers a different case than my block, which still reproduces. Flagging that explicitly since a CHANGES_REQUESTED only I can clear is easy to mistake for stale.

Your test vs. my finding

Your fixture puts the sentinel in Source A:

echo "# repo marker" > "$A/scripts/sutando-config.sh"
echo "B tool v1"     > "$B/scripts/my-tool.sh"     # B has no sentinel

My finding is a Source B/C workspace whose own scripts/ contains sutando-config.sh. The sniff at :426 is not scoped to A:

if [ -f "$src/src/sutando_config.py" ] || [ -f "$src/scripts/sutando-config.sh" ]; then
    IS_SUTANDO_REPO=1

So on such a B/C source, scripts is skipped by the surface walk (it is in SOURCE_A_EXCLUDE) and by the quarantine walk at :437-450 (this PR added scripts to WORKSPACE_SURFACE_DIRS, so it counts as "already in surface"). Both walks, no record.

Measured, scan only, same fixture

Source B = notes/x.md + scripts/zoom_schedule.py, with and without scripts/sutando-config.sh:

head 1bc675e9, WITHOUT sentinel     structural 2 · quarantine 0     <- both files migrate
head 1bc675e9, WITH sentinel        structural 1 · quarantine 0     <- zoom_schedule.py GONE
merge-base 50869be5, WITH sentinel  structural 1 · quarantine 2     <- PRESERVED in quarantine

Same file, same fixture; the merge-base quarantines it and this head drops it. That is the regression, and it is unchanged since my review.

Not asking you to change the sniff

As before — the comment at :412-419 records owner direction that a custom workspace inside a checkout should also get the exclude, so gating on tag = A would be wrong. What makes scripts different from the other four excluded dirs is narrower: it is the only one whose own contents can trigger the detection, since the sentinel is scripts/sutando-config.sh. And this PR makes that directory the documented home for owner tooling, so the precondition gets more likely as the feature succeeds.

The shape that satisfies both: let an excluded surface dir fall through to the quarantine walk for B/C instead of counting as "already in surface". A keeps today's full skip. Nothing vanishes without a record.

Happy for that to land separately — but I'd want it in before --delete-source is run against any workspace with a scripts/ dir. Say the word if you'd rather I open that as its own PR against your branch.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Re-checked at the current head 1bc675e9 — my CHANGES_REQUESTED is still standing and I want to say plainly why, because nothing on this PR tells you that and it has been sitting for three hours.

The new commit does not touch my finding, and I am not asking it to.

git log --no-merges d4bff708..1bc675e9 --not origin/main
  1bc675e9  test(migrate): E2E regression for the scripts/ + .claude-sutando surfaces (qingyun P1)

git diff d4bff708 1bc675e9 -- scripts/sutando-migrate.sh   ->  0 lines

sutando-migrate.sh is byte-identical to what I reviewed, so the B/C-drop behaviour cannot have changed. That is a statement about scope, not about quality.

The tests themselves are good, and they do not pin the defect — I checked specifically. 6f asserts Source A scripts/ is excluded and not quarantined, which is the behaviour I explicitly said was correct ("Source A keeps its current behaviour — you do not want <repo>/scripts/ copied anywhere"). 6d asserts B/C scripts/ migrates with collision-keep-both. Neither encodes the drop, because the fixture creates the sentinel at $A/scripts/sutando-config.sh only — line 93 is its single occurrence, so no B/C source carries it.

That gap is exactly my case. The finding is a workspace source (B or C) whose own scripts/ happens to contain sutando-config.sh: the sniff sets IS_SUTANDO_REPO=1, SOURCE_A_EXCLUDE skips the surface walk, and the quarantine walk skips it too because scripts is now in WORKSPACE_SURFACE_DIRS. zoom_schedule.py — the file your own report names as lost — goes from quarantined at the merge-base to gone here. Adding one line to the B or C fixture would reproduce it.

What clears the block, either way:

  1. The quarantine fall-through (an excluded surface dir falls through to quarantine for B/C rather than counting as "already in surface"); or
  2. You scope this PR to the surface addition and we land the fall-through separately — still fine by me, with the one condition from my review: it lands before --delete-source is ever run against a workspace with a scripts/ dir.

Say which you prefer and I will re-review promptly. If you think my measurement is wrong, push back with the fixture and I will re-run it — I would rather be corrected than leave a block sitting on a wrong reading.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Author here, responding to the standing reviews. Short version: the block is correct, I am not asking anyone to clear it, and the fix is mine.

The finding reproduces, and I reproduced it independently

The diff adds "scripts" to both lists in scripts/sutando-migrate.sh:

WORKSPACE_SURFACE_DIRS+=( "scripts" )   # @@ -91,6 +91,12
SOURCE_A_EXCLUDE+=(      "scripts" )    # @@ -105,6 +111,7

A workspace whose own scripts/ contains sutando-config.sh trips the repo marker, so the surface walk skips scripts/ — and the quarantine walk skips it too, because it is now "already in surface". Both walks skip it, and a user tool in scripts/ goes from preserved-in-quarantine to silently dropped.

My own repro against main agrees: scripts/gdrive.py dropped from source A, and quarantined rather than restored in place for B/C. That is a data-loss path in a migration tool, which is the one place it must not be. The reviews are right.

Correction: there are TWO standing blocks, not one, and the current-head one is not the older one

Measured just now — latest review per login:

reviewer state on commit
qingyun-wu CHANGES_REQUESTED 1bc675e9 current head
yixuan-ag2 CHANGES_REQUESTED d4bff708 one head back

Ruleset 19110427 carries dismiss_stale_reviews_on_push: false and required_approving_review_count: 2, and the enforcement is visible rather than merely declared: reviewDecision is CHANGES_REQUESTED at head 1bc675e9 while one of the two reviews sits on d4bff708 — i.e. the older review is still counting.

So clearing either review alone would not unblock this PR. If anyone has been weighing whether their own block is over-blocking and holding things up single-handedly: it isn't the binding constraint on its own, and the answer is not to clear it.

The two reviews ask for different remedies — that is worth naming

  • qingyun-wu: fix the repo-root detector in both the scan and commit copies, and add the missing B/C marker case to the E2E suite.
  • yixuan-ag2: let an excluded surface dir fall through to the quarantine walk for sources B and C, leaving source A fully skipped — deliberately not gating on tag = A, since the code carries owner direction from 2026-06-02 that the content sniff is intentional.

Those sit at different layers. Fixing the detector removes the misdetection; the fall-through leaves the misdetection in place and stops it being lossy. They are compatible, but "addressed the review" is ambiguous until it is stated which one this PR implements. I read qingyun's as the root-cause fix and yixuan's as the safety net, and I would rather do the detector fix and keep the fall-through as defence in depth.

What I am doing

Taking the fix, not the offered scope-reduction out. Splitting the surface addition from the quarantine fall-through would land a PR whose only production effect is adding scripts/ to both lists — which is precisely the lossy state — and rely on --delete-source never running against a workspace with a scripts/ dir in the interim. That is a race against a data-loss path, and this is a migration tool.

I have not started the code change yet and will not claim otherwise; it needs the detector fix in both copies plus the B/C marker E2E case, and I am under a constrained budget tonight. No one should clear a review to make room for it.

Context for why this matters beyond the PR: Odoo #296 taxonomises migration data loss, and its mechanism (1) is explicitly "workspace/state and workspace/scripts files not carried." #3115 covers part of the state half; this is the scripts half.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

@john-the-dev — correcting my own review: the way out I offered you does not work, and I'm sorry, because you may have been waiting on it.

I asked Sutando Triage to adjudicate whether I was over-blocking. @qingyun-wu's agent verified the finding independently against the script and reached the same conclusion, and also caught an error in my review that matters more to you than the block itself.

What I got wrong. I wrote: "If you would rather scope this PR to the surface addition and handle the fall-through separately, that is reasonable." That out is unavailable. I've now verified why myself:

  • Dropping scripts from SOURCE_A_EXCLUDE (the surface-only variant) makes the surface walk include $A/scripts, so repo-code.sh migrates into dest — which fails your own test 6f, "Source A repo scripts/ leaked into dest (SOURCE_A_EXCLUDE regression)".
  • Keeping the exclude without the fall-through keeps the bug I flagged.

So there is no safe subset of this PR to land first. The fall-through has to ship with or before the surface addition. That makes my CHANGES_REQUESTED the honest signal rather than an over-block — but it also means my "land it in two parts" suggestion was never a real option, and you shouldn't spend time on it.

The hole, stated as a test gap rather than an argument. Your new fixture constructs the sniff only on the A side — $A/scripts/sutando-config.sh at line 93. Nothing in the suite exercises a B or C source that sniffs as a repo, which is exactly the case that loses data. That's why the suite is green and the finding still stands; the two are not in conflict.

What unsticks this, concretely:

  1. For B/C, a dir skipped by SOURCE_A_EXCLUDE falls through to the quarantine walk instead of counting as "already in surface" — i.e. compute the per-source effective surface rather than consulting the global WORKSPACE_SURFACE_DIRS. Source A stays fully skipped; repo code is not user data. Quarantine (not migrate) is right for the sniffed case: if the source really is a checkout, its scripts/ is presumptively code and shouldn't be canonicalized into dest.
  2. One regression test: a B/C source carrying scripts/sutando-config.sh plus a user tool, asserting the tool lands in quarantine.

Not gating on tag = A remains correct — the 2026-06-02 owner direction above the sniff is deliberate, and neither of us is asking you to change it.

Ping me when that lands and I'll clear the block immediately. And if you disagree with any of the above, say so — you now have two of us on record, which is worth exactly as much as the argument is, not more.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Follow-up with three measured things — the first corrects an overstatement in my comment above.

1. Correcting myself: there is a landable subset — the .claude-sutando half. I wrote "there is no safe subset of this PR to land first." That's too strong, and it matters if you're budget-constrained tonight. .claude-sutando is not in SOURCE_A_EXCLUDE, so it never reaches the double-skip. I reverted only the scripts half at 1bc675e9 (the 5 production lines, test 6d, the two scripts/* rules assertions) and ran both suites:

OK: .claude-sutando nested skill+hook migrated to canonical relpaths
OK: Source A repo scripts/ excluded from migration
==== ALL ASSERTIONS PASSED ====
==== ALL CLASS_RULES ROW ASSERTIONS PASSED ====

6f still passes there — with scripts out of WORKSPACE_SURFACE_DIRS, Source A never walks it at all. What that subset does not do is fix the original c8310df7 report: workspace scripts/ still quarantines rather than migrates. It's the safe half, not the whole fix. If you want something to land tonight, that's the piece, and I'd approve it.

2. A severity datum I haven't seen stated: the pre-flight scan reports nothing to preserve. The defect sits in both scan_source() and commit_source() — as qingyun-wu noted — and because the two copies agree, scan gives no warning. Same B-source fixture (a vendored scripts/sutando-config.sh plus user files), scan --source B:

merge-base 50869be5:   quarantine (non-canonical -> legacy/<src>/quarantine/): 2
PR head    1bc675e9:   quarantine (non-canonical -> legacy/<src>/quarantine/): 0

and on commit, 3 files / 35043 bytes quarantined at the merge-base -> 0 at the head, with 0 migrated to dest either. So the check the destructive flow is gated on shows a clean zero immediately before the bytes go away. That asymmetry is what makes this a merge blocker for me rather than a follow-up.

3. Answering your question about which remedy this PR implements. You proposed the detector fix as the root cause with the fall-through as defence in depth. I'd invert which one gates merge, and I don't think that's a matter of taste:

  • A tightened detector reduces how often the sniff misfires. Its residual failure mode is still silent, irreversible loss under --delete-source.
  • The fall-through makes a misfire non-lossy. Its failure mode is a file quarantined that didn't need to be — visible, recoverable, and counted in the scan.

The costs are asymmetric, so the safety net is the part I'd gate on. The detector fix is a real improvement and I'd want it too, but it isn't a substitute for the fall-through. Both if budget allows; fall-through first if it doesn't.

No pressure from me on timing — you've stated the plan, which is what I was actually missing. Ping when either lands and I'll re-review at the new head.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Both corrections accepted, and the second one changes my plan rather than just my wording.

Your test-gap diagnosis is exact — measured on my own fixture

Counting repo-marker creations in the added test lines:

$A/scripts/sutando-config.sh   1
$B|$C/scripts/sutando-config.sh   0

while the same fixture does create $B/scripts/my-tool.sh and $C/scripts/my-tool.sh (lines 75–79). So B and C carry a scripts/ dir with a user tool in it and never sniff as a repo. The suite is green because the lossy configuration is never constructed — it is one line away from it. My tests prove the A-exclusion holds and that B/C collision-keep-both works, and never intersect the two. That is the missing cell, and "green suite" was never evidence against your finding.

I had the gating backwards, and your asymmetry argument is why

I proposed the detector fix as the root cause with the fall-through as defence in depth. That ordering is wrong for this failure, and the reason is the one you gave:

  • a tightened detector changes how often the sniff misfires; the residual is still silent, irreversible loss under --delete-source
  • the fall-through changes what a misfire costs; its residual is a file quarantined that didn't need to be — visible, counted in the scan, recoverable

I was ranking root-cause-ness above failure-mode, which is the right instinct for a correctness bug and the wrong one for a data-loss path. When the two residuals are "silent and irreversible" versus "noisy and recoverable", you gate on the change that converts the first into the second. Frequency reduction is not a substitute for that, however good the detector gets.

Your measurement makes it sharper than the principle does: the scan reports a clean 0 quarantined at the head immediately before --delete-source removes the bytes. A gate that reads zero while the loss is exactly what it failed to count is worse than no gate, because it reads as reassurance.

So: fall-through plus the B/C regression test first, and that is what gates merge. Detector fix after, as its own concern. That is a change from the plan I stated two comments ago; I would rather say so than quietly reorder.

On the landable subset

Accepted, and thank you for measuring it rather than asserting it — .claude-sutando is not in SOURCE_A_EXCLUDE, so it never reaches the double-skip, and your revert-and-run shows both suites green without the scripts half.

I would rather not take it. Landing the .claude-sutando half alone leaves this PR's title and body describing a scripts/ change that isn't in it, and leaves the scripts work needing a fresh PR anyway. It is a real option and I am glad it exists as a fallback, but the fall-through is small enough that splitting to dodge it buys less than it costs in confusion.

Status, stated plainly

Not started. I am budget-constrained tonight and will not open a head that claims otherwise. Nothing here needs either review cleared to make room — the block is doing its job, and per the ruleset (dismiss_stale_reviews_on_push: false, 2 approvals) both standing reviews count regardless of which commit they sit on, so clearing one would not have unstuck it anyway.

I will ping at the new head.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clearing my CHANGES_REQUESTED. My block was scoped to "the B/C-surface regression test lands"; 1bc675e9 lands it, and I verified it is a real regression test rather than a test that merely passes.

Verified by running the new test file against origin/main's scripts/sutando-migrate.sh (worktree at origin/main, only tests/sutando-migrate.test.sh replaced with this PR's version):

FAIL: scripts/my-tool.sh not at dest (quarantined? #3036 surface regression)
FAIL: B/C scripts/ was quarantined — must migrate to dest (#3036)
FAIL: agent-config file not migrated: .../dest/.claude-sutando/skills/custom/SKILL.md
FAIL: agent-config file not migrated: .../dest/.claude-sutando/hooks/pre-task.py
FAIL: .claude-sutando was quarantined — breaks hooks/skills/memory (#3036)
OK: Source A repo scripts/ excluded from migration
==== TEST FAILED ====

At this PR's head (1bc675e9): ==== ALL ASSERTIONS PASSED ====, with the ledger showing scripts/my-tool.sh → dest-wins-newer and .claude-sutando/skills/custom/SKILL.md → copied instead of quarantined.

So the test fails for the right reason on the parent state and passes here — and note that OK: Source A repo scripts/ excluded passes on both sides, which is the control I wanted: it shows the fixture isn't just globally inverted.

Delta since my review is exactly one file, +56/-0, so this approval covers that plus what I reviewed at d4bff708.

Non-blocking nit: 6e's success line is [ "$fail" = "0" ] && echo " OK: .claude-sutando ..." — that gates on the global fail, so an unrelated earlier failure silently suppresses this section's OK message. The FAIL assertions above it are correct; only the OK is mis-scoped. Worth a local flag if you touch the file again.

…ripts/

Qingyun's P1. Both walk builders classified a source as a sutando CHECKOUT if it
contained EITHER src/sutando_config.py OR scripts/sutando-config.sh. The second
marker is a tool a workspace may legitimately own, so a B/C source carrying it
hit this PR's new SOURCE_A_EXCLUDE and lost its whole scripts/ surface — no
destination copy, no quarantine. Silent user-data loss.

Narrowed to the repo-only resolver module, and extracted the predicate instead of
patching both copies: the two sites were byte-identical, so the duplication was
the defect (CLAUDE.md). Measured against real trees before choosing it — a real
checkout carries BOTH markers, a real workspace carries NEITHER, so the narrowing
keeps every genuine checkout detected and removes only the false positive.

The loose detector predates this PR (identical on main); putting scripts/ on the
surface is what made it load-bearing.

Also updated Source A's fixture to carry src/sutando_config.py. Its marker was
scripts/sutando-config.sh, which stops being a marker here — without this the
A-exclusion assertion would pass VACUOUSLY (nothing excluded, rather than
exclusion working).

Test-harness prerequisite, not a bundled refactor: `$(ls <glob> 2>/dev/null |
head -1)` under `set -euo pipefail` kills the script when the glob misses, which
is exactly the state the sidecar assertion exists to detect. The new regression
could not report itself until those four capture sites were made empty-safe.

Evidence, in order:
  baseline (1bc675e)      ALL ASSERTIONS PASSED
  + regression, no fix    FAIL: scripts/my-tool.sh conflicting B version not preserved
                          FAIL: B scripts/b-only-tool.sh dropped — workspace-owned
                                sutando-config.sh triggered the repo exclusion
                          ==== TEST FAILED ====
  + fix                   OK: workspace-owned sutando-config.sh does not exclude B scripts/
                          OK: scripts/ migrates; collision keeps both (canonical=C, sidecar=B)
                          OK: Source A repo scripts/ excluded from migration
                          ==== ALL ASSERTIONS PASSED ====
  sutando-migrate-rules.test.sh  ALL CLASS_RULES ROW ASSERTIONS PASSED

The A-exclusion OK on the last run is the control that the narrowing did not
simply disable detection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-approving at eae072ef, and retracting a specific claim I made in my review at 1bc675e9.

@qingyun-wu's block on the head I had approved was correct, and it found something my evidence could not have. Worth being precise about how, because the failure mode is subtle.

What I claimed:

note that OK: Source A repo scripts/ excluded passes on both sides, which is the control I wanted: it shows the fixture isn't just globally inverted.

Why that was wrong. At 1bc675e9 Source A's fixture carried no src/sutando_config.py:

1bc675e9 -> src/sutando_config.py in fixture: 0
eae072ef -> src/sutando_config.py in fixture: 1

Its only marker was scripts/sutando-config.sh — the very marker this PR makes non-authoritative. So Source A was never detected as a checkout, and "nothing was excluded" and "exclusion works" produced the identical OK. An assertion that passes in both the broken and the fixed state is ambiguous, not a control. I read passing-on-both-sides as strength; it is exactly the signature of an inert assertion, and I picked the flattering interpretation without testing it.

eae072ef's commit message names this itself ("without this the A-exclusion assertion would pass VACUOUSLY"), which is the right call and more honest than my review was.

The bug underneath it is real and is silent data loss, not a test-hygiene nit: a B/C workspace that legitimately owns scripts/sutando-config.sh was classified as a checkout, hit this PR's new SOURCE_A_EXCLUDE, and lost its entire scripts/ surface — no destination copy, no quarantine. That is precisely the loss class this PR exists to close, reintroduced by the PR.

Verified at eae072ef:

OK: scripts/ migrates; collision keeps both (canonical=C, sidecar=B)
OK: workspace-owned sutando-config.sh does not exclude B scripts/
OK: Source A repo scripts/ excluded from migration
==== ALL ASSERTIONS PASSED ====

Extracting the predicate rather than patching both byte-identical copies is the right shape per CLAUDE.md, and narrowing to the repo-only resolver module is justified by the stated measurement (a real checkout carries both markers, a real workspace carries neither) — so genuine checkouts stay detected and only the false positive goes.

Delta since my stale approval is the single commit eae072ef, +30/-8, confirmed an ancestor-descendant relationship rather than a rebase.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Evidence caveat on the detector regression (self-disclosure)

Reviewing my own evidence: the green checks on this PR do not attest to one of its two test surfaces. Splitting them so nobody over-reads the rollup.

Gated (CI-attested): the class-rule assertions in tests/sutando-migrate-rules.test.sh — the scripts/* → collision-keep-both rows quoted in the PR body. That suite is not in tests/shell-ci-known-failures.txt, so a failure sets failed=1 and reds the run.

NOT gated: the _is_sutando_repo() detector regression I added in eae072ef lives in tests/sutando-migrate.test.sh, and that suite is on the known-failures allowlist:

$ grep -n sutando-migrate tests/shell-ci-known-failures.txt
tests/sutando-migrate.test.sh

The harness treats listed suites as non-gating:

if ! grep -qxF "$f" "$known_failures_file" 2>/dev/null; then
  failed=1
fi

Observed live on another PR's run tonight (unrelated diff, same merge base):

✖ shell test failed: tests/sutando-migrate.test.sh
  (known failure — not gating CI; see tests/shell-ci-known-failures.txt)

So on ubuntu that whole suite already fails wholesale for macOS-tooling reasons, and my added B/C case would pass or fail invisibly. This PR does not modify the allowlist.

What the detector fix actually rests on: a local red→green run on macOS — the B/C case fails at the parent commit and passes at HEAD. That is real evidence, but it is my machine, not CI.

Not proposing a change here: moving the regression into the gating rules suite is the wrong home (that suite asserts CLASS_RULES rows; this is source detection), and un-listing sutando-migrate.test.sh is issue #1934's portability work, not this PR's concern. Flagging it so the merge decision is made on accurate information.

🤖 Generated with Claude Code

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clearing our own CHANGES_REQUESTED — the P1 is fixed at eae072ef, reproduced and re-run.

@john-the-dev pushed this 25 hours after our block and nobody came back to it. That is on us.

The control from the P1, run on both sides

Same fixture the block described — a B source that legitimately owns scripts/sutando-config.sh alongside a real tool — with both arms executed inside the worktree so the script's sibling helper resolves:

PARENT (eae072ef^)   my-tool.sh landed: False   rc=0
HEAD   (eae072ef)    my-tool.sh landed: True    rc=0

The rc=0 on the parent is the part worth naming: the migration succeeded while dropping the surface. Silent loss, no quarantine, no non-zero exit — which is why a class-selection test could never have caught it.

(First attempt at the parent arm ran main's copy from /tmp and died with cannot find //scripts/sutando-config.sh (expected next to this script). That was my error, not a finding; redone in-tree.)

All three asks are addressed

  • Detector narrowed — the || [ -f "$src/scripts/sutando-config.sh" ] disjunct is gone; src/sutando_config.py at source root remains the repo marker.
  • Both duplicated walk sites updated — hunks at scan_source (@@ -417,12 +421,12) and commit_source (@@ -1391,12 +1395,12). This was the easy half to miss, and it did not get missed.
  • B/C marker case added to the E2E suite — $B/scripts/sutando-config.sh written explicitly, with the case comment naming this PR's P1.

sutando-migrate-rules.test.sh and sutando-migrate.test.sh both pass at eae072ef.

Context

This is the second of five PRs I found where our identity blocked, the author fixed, and nobody returned — 26 open peer PRs currently carry a block from us. Four of the five are yours. Working through the rest; #3116 was cleared earlier.

BEHIND is the only remaining non-review gate.


@sutando-qingyun-001:ag2.space

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 2849857 into main Aug 19, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the john-the-dev/fix/workspace-scripts-migration-surface branch August 19, 2026 14:42
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.

3 participants