Skip to content

fix(ci): re-measure the drifted all-chunk bundle ceiling - #8935

Merged
NicholasRBowers merged 1 commit into
mainfrom
fix/remeasure-all-chunk-ceiling
Sep 6, 2026
Merged

fix(ci): re-measure the drifted all-chunk bundle ceiling#8935
NicholasRBowers merged 1 commit into
mainfrom
fix/remeasure-all-chunk-ceiling

Conversation

@CrysisDeu

Copy link
Copy Markdown
Collaborator

Problem / Motivation

Unblocker: main's all chunk (the eager i18n catalogs) has drifted to within 0.4% of its own bundle-size ceiling, so the Bundle Size Gate fails on the merge ref of any feature PR that ships a normal set of translated strings instead of on the new library or surface the ceiling exists to catch. First hit on #8924 (13 catalogs × 52 lines, ~55 KB of strings): FAIL assets/all-*.js: 10.26 MB exceeds its 10.24 MB budget by 13.7 KB.

no linked issue: gate breakage found while driving PR #8924; filed as a direct unblocker per the t-entry (#8412) and App-entry (#8519) precedents for the identical recurrence.

Why it matters

Every open PR that adds i18n keys inherits this red on its merge ref, and the fix would otherwise get folded into unrelated feature PRs one at a time — each one re-justifying the same ceiling move.

What changed (motivation → approach → change)

Attribution (measured, not assumed). main @ 3a6478967 alone, with no PR code, builds assets/all-DdlRMre2.js at 10,700.93 kB = 10,700,930 B (10450 KiB) — CI job 101448257266 on main's own ci.yml run, passing with 40,830 B (0.38%) of headroom under the 10490 KiB (10,741,760 B) ceiling. #8924's 13 catalogs add ~54.8 kB on top, landing at 10,755,750 B. Nothing new reached the chunk: the app page in #8924 sits behind a lazy import() in src/apps/builtinRegistry.ts and the App chunk is unaffected.

Change. all: 10490 * KBall: 10975 * KB (= 10450 KiB measured + ~5% headroom), matching the convention documented on the t and App entries. The entry's comment records the measurement, date, and recurrence class so the next re-measure has the trail.

Tests

N/A — CI gate constant only. src/test/checkBundleSize.test.ts and src/test/bundleReport.test.ts do not pin the all value (verified by grep); node -e "import('./scripts/check-bundle-size.mjs')" loads and reports all = 11238400.

Manual verification

N/A — the attribution above is CI's own measurement on main; this PR's own Bundle Size Gate run is the verification.

Screenshots / video

Why no screenshot: CI gate constant only; no runtime code.

Related Issues

Unblocks #8924. Precedents: #8412 (t entry), #8519 (App entry).

Pattern harvest

Not generalizable: the t entry's comment already documents this recurrence class (a ceiling with <1% headroom fails on drift, not on defects) and the 5% headroom convention is the standing remedy; this PR applies it to the all entry.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

main alone builds the eager i18n catalog chunk (`all`) at 10,700,930 B
(10450 KB) against a 10490 KB ceiling -- 0.4% headroom -- so the Bundle
Size Gate fails on the merge ref of any feature PR that ships a normal
set of keys across the 13 catalogs, instead of on the new library or
surface the ceiling exists to catch. First seen on #8924 (13 catalogs x
52 lines, ~55 KB, over by 13.7 KB).

Re-measure to 10975 KB = 10450 KB measured + ~5% headroom, the
convention the `t` (#8412) and `App` (#8519) entries already follow for
the identical recurrence. CI gate constant only; no runtime code.
@CrysisDeu
CrysisDeu requested a review from a team as a code owner September 6, 2026 07:51
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of a7b7d21676828bd090fe3c2b6556e6109e728248 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

This PR changes a single CI bundle-size constant in website/scripts/check-bundle-size.mjs — no runtime code, no user-visible controls, strings, or states. The blind read was not performed because there was nothing to screenshot, which is correct for a change with no visual delta. No UX surface exists to review.

UX-Verdict: PASS

CI gate constant only — no user-visible surface changes, so no comprehension, flow, or evidence requirements apply.

[UX-REVIEWED] a7b7d21

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of a7b7d21676828bd090fe3c2b6556e6109e728248 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

A drifted CI ceiling re-measured per the file's own documented convention; attribution against main's tip rules out masking a real regression.

[DESIGN-REVIEWED] a7b7d21

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of a7b7d21676828bd090fe3c2b6556e6109e728248 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] a7b7d21

False positive or not applicable? A repository writer can comment:
/ai-review override gpt a7b7d21676828bd090fe3c2b6556e6109e728248: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed a7b7d21676828bd090fe3c2b6556e6109e728248 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] a7b7d21

Verdict parsed from the review's SHA-scoped output markers for commit a7b7d21676828bd090fe3c2b6556e6109e728248.

False positive or not applicable? A repository writer can comment:
/ai-review override fable a7b7d21676828bd090fe3c2b6556e6109e728248: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 6, 2026
@NicholasRBowers
NicholasRBowers enabled auto-merge (squash) September 6, 2026 08:28

@NicholasRBowers NicholasRBowers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tier 1 auto-approve: fix (1 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: single-file CI fix re-measuring the drifted all-chunk bundle ceiling with a clear root cause (headroom eroded to 0.4%, gate fails on routine string growth).

@NicholasRBowers
NicholasRBowers merged commit cb3aa2c into main Sep 6, 2026
64 checks passed
@NicholasRBowers
NicholasRBowers deleted the fix/remeasure-all-chunk-ceiling branch September 6, 2026 08:29
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 6, 2026
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Review round 16 on #8924 (GPT 5.6 on 7a680b3):
- A root whose identity could not be read at validation time was handed
  to scan() as expected_identity=None, which scan reads as "no caller
  pinned this" and skips the root-swap comparison. _resolve_root now
  refuses such a root (400 folder_scan_root_invalid) so the endpoints
  never scan unpinned; test pins that no scan runs. The additive-scaffold
  property test builds its own state and now opens the enablement gate
  like the fixture does (it was the one red on the previous head).

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Review round 16 on #8924 (GPT 5.6 on 7a680b3):
- A root whose identity could not be read at validation time was handed
  to scan() as expected_identity=None, which scan reads as "no caller
  pinned this" and skips the root-swap comparison. _resolve_root now
  refuses such a root (400 folder_scan_root_invalid) so the endpoints
  never scan unpinned; test pins that no scan runs. The additive-scaffold
  property test builds its own state and now opens the enablement gate
  like the fixture does (it was the one red on the previous head).

Review round 17 on #8924 (GPT 5.6 on 33ec3c6):
- Create re-resolved the submitted root; with a component swapped for a
  symlink after the preview (and nothing selected, so the offered-set
  cross-check had nothing to catch), it would scan the redirected tree
  and persist a folder for an unpreviewed directory. Create now requires
  the submitted root to be a realpath fixed point: a re-resolution that
  lands elsewhere is refused (400 folder_scan_root_invalid + SEL denied)
  before any scan. Test with a root replaced by a symlink.
- The empty-preview branch never rendered the stale notice, so a
  root-moved refusal of the root-only create just disabled the button.
  The notice + Re-scan action is now a shared StaleNotice rendered by
  both branches; test.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Review round 16 on #8924 (GPT 5.6 on 7a680b3):
- A root whose identity could not be read at validation time was handed
  to scan() as expected_identity=None, which scan reads as "no caller
  pinned this" and skips the root-swap comparison. _resolve_root now
  refuses such a root (400 folder_scan_root_invalid) so the endpoints
  never scan unpinned; test pins that no scan runs. The additive-scaffold
  property test builds its own state and now opens the enablement gate
  like the fixture does (it was the one red on the previous head).

Review round 17 on #8924 (GPT 5.6 on 33ec3c6):
- Create re-resolved the submitted root; with a component swapped for a
  symlink after the preview (and nothing selected, so the offered-set
  cross-check had nothing to catch), it would scan the redirected tree
  and persist a folder for an unpreviewed directory. Create now requires
  the submitted root to be a realpath fixed point: a re-resolution that
  lands elsewhere is refused (400 folder_scan_root_invalid + SEL denied)
  before any scan. Test with a root replaced by a symlink.
- The empty-preview branch never rendered the stale notice, so a
  root-moved refusal of the root-only create just disabled the button.
  The notice + Re-scan action is now a shared StaleNotice rendered by
  both branches; test.

Review round 18 on #8924 (GPT 5.6 on db55a78):
- StaleNotice's ErrorNotice carries the hand-off decision comment
  (errors-use-error-notice): the extraction had left it at the call
  sites only.
- The round-17 create test read the response body after the test client
  had closed (buffered on Linux, not on Windows) — read inside the scope.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Review round 16 on #8924 (GPT 5.6 on 7a680b3):
- A root whose identity could not be read at validation time was handed
  to scan() as expected_identity=None, which scan reads as "no caller
  pinned this" and skips the root-swap comparison. _resolve_root now
  refuses such a root (400 folder_scan_root_invalid) so the endpoints
  never scan unpinned; test pins that no scan runs. The additive-scaffold
  property test builds its own state and now opens the enablement gate
  like the fixture does (it was the one red on the previous head).

Review round 17 on #8924 (GPT 5.6 on 33ec3c6):
- Create re-resolved the submitted root; with a component swapped for a
  symlink after the preview (and nothing selected, so the offered-set
  cross-check had nothing to catch), it would scan the redirected tree
  and persist a folder for an unpreviewed directory. Create now requires
  the submitted root to be a realpath fixed point: a re-resolution that
  lands elsewhere is refused (400 folder_scan_root_invalid + SEL denied)
  before any scan. Test with a root replaced by a symlink.
- The empty-preview branch never rendered the stale notice, so a
  root-moved refusal of the root-only create just disabled the button.
  The notice + Re-scan action is now a shared StaleNotice rendered by
  both branches; test.

Review round 18 on #8924 (GPT 5.6 on db55a78):
- StaleNotice's ErrorNotice carries the hand-off decision comment
  (errors-use-error-notice): the extraction had left it at the call
  sites only.
- The round-17 create test read the response body after the test client
  had closed (buffered on Linux, not on Windows) — read inside the scope.

Review round 19 on #8924 (GPT 5.6 on 13d3d36):
- The Windows scandir fallback stated its check-then-read window as
  unclosable. It is closed by HOLDING rather than racing: a CreateFile
  handle (BACKUP_SEMANTICS | OPEN_REPARSE_POINT, share READ|WRITE but not
  DELETE) is taken before the identity and resolution checks and released
  after the listing is consumed; while held, neither the directory nor
  any ancestor can be renamed or deleted, which every junction swap needs
  first. A hold that cannot be taken raises, so the directory is reported
  unread instead of read unpinned. Tests pin the hold bracketing every
  listed directory's checks and the fail-closed path.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
iamwhatever pushed a commit that referenced this pull request Sep 7, 2026
A chat folder already carries a project_dir and nests via parent_id,
but a monorepo or a directory of sibling repositories needs one folder
per package before per-package steering loads at all — and assembling
N sub-folders by hand is work nobody does.

This adds a pure read-only scanner (project_scan.py) that detects
packages via repository/manifest/deploy-root/workspace-member signals
with two confidence tiers, honoring the project's own .gitignore with
git semantics; two endpoints (scan = dry-run preview, scaffold =
create the confirmed selection through the existing folder create
path); and a manifest-only builtin app providing the preview/confirm
UI. Detailed design notes live in docs/system-specs.

The Store listing carries the fields the current app-listing contract
requires: useCases and configuration key arrays on the APP_MANIFEST_KEY
entry (with the strings in all 13 catalogs), and a screenshots entry
pointing at a real UI capture of the scan preview — taken against a
synthetic monorepo, so the image carries no real project names.

One generated file outside the feature's own sources is updated:
error-code-baseline.json is re-snapshotted because extracting
create_folder_record moved two of chat_folders.py's refusals behind a
FolderCreateError that carries a `code`, so its missing_code count
IMPROVES 21 -> 19 and test_baseline_is_not_stale requires the snapshot
be refreshed. Nothing is regenerated to silence a regression.

Takeover of PR #5890 (author unresponsive 48h after two fix requests).
This revision carries the last open reviewer finding:

- create_folder_record ran _validate_project_dir (realpath + isdir +
  sensitive-path scan) synchronously on the event loop; the scaffold
  calls it once per selected directory, so a slow or network-mounted
  directory stalled every other request for the whole scaffold. The
  validator now runs via asyncio.to_thread, and a thread-identity test
  pins it off the loop (GPT 5.6 blocker on #5890).
- Rebased onto current main: ProjectPicker keeps both main's
  keyboard-isolation barrier and this PR's narrow-viewport max-w
  clamp; error-code-baseline.json carries main's chat_handlers.py
  improvement alongside this PR's chat_folders.py improvement.

Earlier review rounds on #5890 (scaffold write guards, redacted scanner
warnings, .gitignore grammar refusal as DeclarationError, off-loop root
resolution, dropped zero-consumer groups field) are carried unchanged.
The PR no longer touches scripts/check-bundle-size.mjs: main re-measured
the `all` (eager i18n catalog) ceiling in #8935 after this PR's 13
catalogs tipped the drifted 10490 KB budget, so the entry's comment
about this feature now lives on main.

Review round 2 on #8924 (GPT 5.6 + UX lanes on 0e9947f):
- Every error the page renders goes through the shared ErrorNotice
  (AUTOSDE errors-use-error-notice, blocking): the refused-root notice
  under the field, the two create-refusal notices, and the per-folder
  refusals in the results card. All four leave askAgent off with a
  comment naming the unsaved draft (typed root / ticked selection) the
  hand-off would unmount. Test ids and the field's aria-describedby
  target are preserved.
- A failed re-scan no longer destroys the hand-tuned preview: scanMut
  clears only the root error and stale prompt up front and replaces
  the preview/selection/result on SUCCESS; while a scan is in flight
  the preview card is a disabled fieldset, so a stale preview cannot be
  confirmed either. Pinned by a test that hand-tunes, re-scans into a
  500, and asserts the selection count is unchanged and re-enabled.
- "Offered" tier badge renamed to "Possible match" across all 13
  catalogs (cold read could not tell it from "Confident").
- The raw machine-readable `code` line under a failed row is gone; the
  server prose already carries the message.
- Screenshots re-captured from HEAD via an isolated capture entry
  (website/capture/project-scaffolder.*, scripts/capture-project-
  scaffolder.mjs) that mounts the real page against a synthetic
  monorepo: scan-preview, nested-open, results, empty, stale,
  root-refused, rescan-failed. The store listing screenshot is the
  same new scan-preview frame.

Review round 4 on #8924 (GPT 5.6 on f422e14):
- _declared_members accumulated member paths with list.extend at two
  levels, so a declaration repeating one glob (a 512 KiB file admits
  tens of thousands) re-appended the same matched directories per
  repeat and peak memory scaled with patterns x matches. Both
  accumulators are now order-preserving dicts keyed on the path, so the
  bound is the number of unique members. Pinned by a test that declares
  "packages/*" 2000 times and asserts _declared_members hands back
  exactly the two unique members.

Review round 5 on #8924 (GPT 5.6 on 895d5c1):
- The stale-selection prompt originates from a rejected create (400
  folder_scaffold_selection_stale), so it is an error by origin under
  AUTOSDE errors-use-error-notice: its sentence now renders through
  ErrorNotice (askAgent off, the ticked selection is unsaved), with the
  dropped paths and the Re-scan action beneath. Test pins the alert.
- _resolve_root's ancestor-of-sensitive-root refusal now writes a SEL
  denied api-access event (operation chat.folder_scan_root) before
  raising, matching every other security refusal in the module. Test
  pins one denied event per endpoint.

Review round 6 on #8924 (GPT 5.6 on 0c4d88c):
- Create acts on scan.root, so a root typed after the scan but never
  scanned could confirm the PREVIOUS project's preview. The page now
  remembers the field value each preview was scanned from; while the
  field differs, both Create buttons are disabled and a hint asks for a
  scan. The stale prompt's Re-scan re-runs that remembered input (not
  the resolved root) so a `~` or symlinked spelling does not read as
  drift. Test pins disabled -> re-scan -> enabled; root-drifted.png.

Review round 8 on #8924 (GPT 5.6 on e170e39):
- The root form row (field + Browse + Scan) had no narrow provision
  (AUTOSDE narrow-viewport-required, blocking): at 320px with long
  translated labels the path field was squeezed. Narrow-first now: the
  field spans the row and the two actions share the row beneath it;
  from `sm` up all three sit in one row as before. Capture harness
  gained a 320px scene (narrow-320.png) asserting the field is wide
  and the actions sit below it.

Review round 9 on #8924 (UX CONCERNS on a2e5b56):
- Results card lists the already-existing paths like the created ones,
  so the tally can be reconciled; the capture fixture no longer invents
  a skip the server cannot produce (root_existing drives the one skip)
  and its warning uses the scanner's shipping string.
- One rule for "this preview cannot be confirmed": Create is disabled
  while the selection is stale, as it already was while the root field
  had drifted; a preview kept through a failed re-scan is labelled as
  the last successful scan's.
- folder_project_dir_moved prose reworded from machine language to
  "That folder was moved or replaced after the scan — re-scan and retry".
- Row label "Signals" -> "Why it matched" across 13 catalogs.
- Capture harness gains a 390px picker-open scene proving the shared
  ProjectPicker's viewport clamp (picker-open.png).

Review round 10 on #8924 (UX CONCERNS on 59ee27b):
- _validate_project_dir's two refusals now read "Project directory must
  be an absolute path / an existing directory" instead of naming the
  project_dir field; the manual folder flow shares the string, so parity
  holds. The moved-directory prose says "directory", reserving "folder"
  for the sidebar like every other string on the page.
- Capture harness gains a create-refused scene (whole-call 500 beside
  the Create button through ErrorNotice).

Review round 11 on #8924 (GPT 5.6 + UX on 12b51a2):
- The scan root was validated on one thread and pinned (lstat) on
  another, leaving a window in which an ancestor swapped for a symlink
  would redirect the whole walk into a tree the validation never saw.
  _resolve_root now records the root's (st_dev, st_ino) in the same
  breath as the validation and hands it to scan(expected_identity=...);
  scan refuses with RootChangedError before its first read when the
  name now reaches a different inode, and both endpoints answer 400
  folder_scan_root_invalid with a SEL denied audit. Pinned by unit
  tests (matching / mismatched / symlink-swapped identity) and an
  endpoint test that swaps the root between validation and scan.
- UX copy: the picker trigger reads "Choose directory" (no second
  "Browse" beside the picker's own tab); the primary button reads
  "Create sidebar folders"; the counter drops "Root folder +" when the
  root already has its folder. The create-refused and rescan-failed
  capture scenes now use refusal prose the server really sends.

Review round 12 on #8924 (UX on 800a517):
- One `cannotConfirm` rule now gates both Create buttons: root drifted,
  selection stale, or the last re-scan failed (the preview on screen is
  the previous scan's). The two moved/replaced refusals share one
  sentence. The disclosure's bulk pair is labelled "Select all inside" /
  "Select none inside" so two identical pairs never sit on one page.
  New capture scenes: root-new (the "Root folder + N" counter) and
  scanning (in-flight, dimmed preview).

Review round 13 on #8924 (UX on df6b8b7):
- A create refused because the ROOT moved (folder_scan_root_invalid)
  is the same situation as a stale selection, so it now takes the same
  path: stale banner + Re-scan, Create disabled until a scan succeeds —
  no more live button over "re-scan and retry" text. Whole-call create
  refusals carry the scope title "No folders were created" so they read
  apart from a per-folder failure in the results. Capture fixture's
  create-refused case is the server's real 429 rate-limit refusal (a
  retryable one, where an enabled button is right); a creating scene
  captures the in-flight create.

Review round 15 on #8924 (GPT 5.6 on d83e09c):
- The scan/create endpoints are plain dashboard routes, not behind the
  app-backend proxy, so the proxy's enablement gate never saw them and a
  dashboard-user token bypasses the app-scope check: the app ships
  defaultEnabled:false yet both endpoints answered for a person who never
  turned it on. Both handlers now refuse with 403 app_not_enabled and a
  SEL denied audit unless is_app_enabled("project-scaffolder"), the same
  shape as the proxy's gate. Test fixture opens the gate for the suite;
  a disabled-app test pins the 403 + audit on both endpoints.

Review round 16 on #8924 (GPT 5.6 on 7a680b3):
- A root whose identity could not be read at validation time was handed
  to scan() as expected_identity=None, which scan reads as "no caller
  pinned this" and skips the root-swap comparison. _resolve_root now
  refuses such a root (400 folder_scan_root_invalid) so the endpoints
  never scan unpinned; test pins that no scan runs. The additive-scaffold
  property test builds its own state and now opens the enablement gate
  like the fixture does (it was the one red on the previous head).

Review round 17 on #8924 (GPT 5.6 on 33ec3c6):
- Create re-resolved the submitted root; with a component swapped for a
  symlink after the preview (and nothing selected, so the offered-set
  cross-check had nothing to catch), it would scan the redirected tree
  and persist a folder for an unpreviewed directory. Create now requires
  the submitted root to be a realpath fixed point: a re-resolution that
  lands elsewhere is refused (400 folder_scan_root_invalid + SEL denied)
  before any scan. Test with a root replaced by a symlink.
- The empty-preview branch never rendered the stale notice, so a
  root-moved refusal of the root-only create just disabled the button.
  The notice + Re-scan action is now a shared StaleNotice rendered by
  both branches; test.

Review round 18 on #8924 (GPT 5.6 on db55a78):
- StaleNotice's ErrorNotice carries the hand-off decision comment
  (errors-use-error-notice): the extraction had left it at the call
  sites only.
- The round-17 create test read the response body after the test client
  had closed (buffered on Linux, not on Windows) — read inside the scope.

Review round 19 on #8924 (GPT 5.6 on 13d3d36):
- The Windows scandir fallback stated its check-then-read window as
  unclosable. It is closed by HOLDING rather than racing: a CreateFile
  handle (BACKUP_SEMANTICS | OPEN_REPARSE_POINT, share READ|WRITE but not
  DELETE) is taken before the identity and resolution checks and released
  after the listing is consumed; while held, neither the directory nor
  any ancestor can be renamed or deleted, which every junction swap needs
  first. A hold that cannot be taken raises, so the directory is reported
  unread instead of read unpinned. Tests pin the hold bracketing every
  listed directory's checks and the fail-closed path.

Supersedes #5890
Closes #2516

Co-authored-by: Billy Gerhard <billygerhard@gmail.com>
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