fix(ci): re-measure the drifted all-chunk bundle ceiling - #8935
Conversation
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.
UX Review (Fable 5) — ✅ PASSUX-level review of This PR changes a single CI bundle-size constant in UX-Verdict: PASS CI gate constant only — no user-visible surface changes, so no comprehension, flow, or evidence requirements apply. [UX-REVIEWED] a7b7d21 |
Design Review (Fable 5) — ✅ PASSDesign-level review of 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 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
NicholasRBowers
left a comment
There was a problem hiding this comment.
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).
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Problem / Motivation
Unblocker: main's
allchunk (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) andApp-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 @
3a6478967alone, with no PR code, buildsassets/all-DdlRMre2.jsat 10,700.93 kB = 10,700,930 B (10450 KiB) — CI job 101448257266 on main's ownci.ymlrun, 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 lazyimport()insrc/apps/builtinRegistry.tsand theAppchunk is unaffected.Change.
all: 10490 * KB→all: 10975 * KB(= 10450 KiB measured + ~5% headroom), matching the convention documented on thetandAppentries. 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.tsandsrc/test/bundleReport.test.tsdo not pin theallvalue (verified by grep);node -e "import('./scripts/check-bundle-size.mjs')"loads and reportsall = 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 (
tentry), #8519 (Appentry).Pattern harvest
Not generalizable: the
tentry'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 theallentry.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)