-
2026-07-18 —
FileUtils.get_object_path: the stack-scan fallback no longer executes caller source files (TDD'd red-first). When an object's class couldn't be resolved to a file viasys.modules/__main__, the fallback walkedinspect.stack()and ranspec.loader.exec_moduleon each frame's source file just to check whether it defines a class with the right name — re-running every side effect in those files. Found live: uitk's newtest_resolve_path_unresolvable_object_returns_nonecallsresolve_path(object()), and exec'ing the stack'spytest/__main__.pylaunched a whole nested test session inside the test (~30s, then the nestedSystemExit(1)— not anException, so it escaped the branch'sexcept Exception— failed the outer test). The scan also keyed its cache by file path intosys.modules(never a hit, and namespace pollution on every miss). Frame globals already ARE each running module's namespace, so the fallback now just checksframe.f_globals— identity match on the object itself (new: also finds functions, whichgetmembers(…, isclass)never could) or a class of the same name — executing nothing; same ValueError contract when nothing matches. The regression pin (test_file.py::test_get_object_path_stack_scan_never_executes_caller_files) proves it in a subprocess: a driver script appends its__name__to a marker on every execution — red showed['__main__', 'goppath_driver'](the file ran twice), green shows one line. An earlier regression test had already stubbedinspect.stack()empty to tiptoe around this branch; it now composes with the fix unchanged. uitk's registry_manager suite: 47/48 + 33s → 48/48 in 1.2s (the 30s was the nested session). Full suite green. -
2026-07-18 —
NamedTupleContainercorrectness pass: plain-tuple rows now support field access (fixes a liveMetadata._batch_getlatent bug),get()honors its documented None-on-no-match,filter/mappreserve the subclass, and__getattr__can no longer recurse on partially-initialized instances. Public surface unchanged. Twelve defects fixed, each pinned red-first (test_namedtuple_container.py+19 → 49, incl. two custom-signature_funcpins — the path uitk'sFileRegistry._record_signaturerides): (1) plain tuples passed asnamed_tupleswere stored raw, so dynamic field access andget()crashed —Metadata._batch_getbuilds exactly that shape, so every batch-metadata query was broken; rows now normalize to the tuple class whenever fields are known. (2)__getattr__looked upself.fieldsthrough itself — a partially-initialized instance (copy/pickle reconstruction) hit RecursionError, socopy.copyof a container was broken outright; field lookup now reads__dict__re-entrancy-safe. (3)get(return_field=…, **conditions)returned[]on no match while its docstring promised None — every ecosystem call site treats the result by truthiness (verified: switchboard_core,ui_handler, both loaders,slots), so the docs won; the one uitk test pinning[]updated. (4)extend([])mis-dispatched into the single-object path (ValueError, or extender misfire with[[]]) — now a no-op likeextend(None). (5) duplicate detection crashed with TypeError on unhashable field values (lists/dicts); signatures fall back to repr, and are computed once per row instead of twice. (6) cross-class namedtuple extends converted positionally — the same field set in a different order silently misaligned values; conversion realigns by name when field sets match (positional stays the fallback, and plain-tuple-rowed containers no longer inherit a latenttuple(*nt)crash). (7) a field-less container never adopted a schema from incoming named tuples, leaving dynamic access dead; the first named-tuple batch now supplies fields + tuple class. (8)modify()validated viahasattr, so tuple method names (count,index) passed validation and blew up as ValueError; validation now checks_fieldsand raises the documented AttributeError. (9)filter/maphardcoded the base class — uitk'sFileContainer(or any subclass) got back a base container with its extra state dropped, carrying a freshly-regenerated tuple class besides; both now clone viacopy.copy(new_clone), preserving subclass, attributes, and the actual tuple class. (10)modify/removerejected negative indices; list semantics now apply (shared_normalize_index). (11) string fields ("name, age city") crashed at class creation or half-worked through substring matching; they now split likecollections.namedtuple(new_normalize_fields, also coveringmetadata["fields"]— which drops the deferred circular-importfrom pythontk import make_iterableworkaround). (12)to_csv/from_csvdocstrings claimed kwargs went to csv.writer/reader (they go toopen()— now stated), andfrom_csvgained thenewline=""the csv module requires. Also:__repr__and AttributeError messages use the subclass name;extend's blanket try/log/re-raise (double-reporting every error) removed;namedtuple-as-type annotations corrected totuple. Registry regenerated — "No public API changes" (signature hints only). Full suite green (2119 tests, 0 failures, 7 skipped); uitk consumers verified (file_manager 31/31, switchboard + sources + ui_handler + runtime loader 203/203). -
2026-07-18 —
logging_mixinquality pass: per-record worker threads removed from the default widget handler, the globallogging.Loggerclass no longer mutated, stream-handler dedup, explicit tee/buffer levels pinned againstset_log_level, styling kwargs unified across every level method, and tables render as one aligned block. Seven defects fixed (all TDD'd red-first). (1)DefaultTextLogHandlerspawned athreading.Timerthread per log record — one OS thread per line, delivery order at the scheduler's mercy, and no GUI-thread safety gained in exchange (appends landed on an arbitrary worker thread — for Qt/Tk, worse than the emitting thread). Appends are now synchronous on the emitting thread, serialized by the handler lock like every stdlib handler; GUI marshaling is explicitly the registered custom handler's job (uitk'sTextEditLogHandleralready posts cross-thread records through a queued Qt signal). (2)patch()installed thelog_timestampproperty onlogger.__class__— the GLOBALlogging.Loggerclass — so assigning.log_timestampon any foreign, unpatched logger (a data descriptor beats an instance attribute) ran our setter and silently replaced that logger's handler formatters withLevelAwareFormatter. Patched loggers are now reassigned to a cached per-base subclass carrying the property (_install_log_timestamp_property; one subclass per base class,TypeErrorfallback to the legacy in-place property for exotic layouts) — the property API is unchanged for patched loggers (smart_bake slots, uitk example) andlogging.Loggerstays clean. (3)add_stream_handlernever deduplicated (file and text-widget handlers did), so repeat calls stacked duplicate console output; now deduped per target stream (exact-type check —FileHandlersubclassesStreamHandler), it gained astream=param, andsetup_logging_redirect's stream branch collapsed into it (same formatter/level wiring, now deduped too). (4)set_log_levelsynced EVERY handler level, clobbering an explicitly-requestedset_log_file(path, "ERROR")/enable_log_buffer(level=…)— an errors-only tee started receiving DEBUG floods the moment the logger was opened up. Explicit (non-NOTSET) tee/buffer levels are now pinned (_pinned_level) and skipped by the sync; default-level handlers track as before. (5)success/result/notice/progressraisedTypeErroron thepreset=/color=styling kwargs their siblings accept — they bypassed_log_customstraight into stdlibLogger.log; all four now route through it, which also picked up a lazy gate: styling/HTML work is skipped entirely when the record's level is disabled (it previously ran before the enabled check). (6)log_tableemitted one record per table line — per-record paragraph spacing plus a proportional font misaligned every table in Qt widget handlers; on a patched logger the whole table is now a singlelog_rawrecord (one monospace block), exactly the fixlog_box/log_groupalready shipped, and its dead exploratory comment block is gone. (7)format_tablemeasured cells withlen()— an emoji/CJK cell (display width 2,len1) misaligned every column after it; it now measures/pads/truncates via the module's own display-width primitives (_display_width/_pad/_truncate), byte-identical output for ASCII tables. Cleanups: dead_select_formatter/_formatter_selectordeleted (no callers ecosystem-wide); the four copy-pasted HTML-strip regex sites collapsed into publicLoggerExt.strip_html(registry +1);_log_rawnow holds the handler lock around both its stream writes and its direct raw emits (serialized with concurrent normal emits, asHandler.handlewould);_coerce_levelgained adefault=param and_set_levelcomposes it;_log_boxgot the@staticmethodits siblings have;except (ImportError, Exception)→except Exception; inlineosimports hoisted; module docstring added.test_logging_mixin.py+10 (synchronous same-thread ordered delivery, global-class purity + foreign-logger immunity, timestamp-property behavior pin, stream dedup, tee/buffer level pinning ×2, styling kwargs on custom levels, disabled-level skip, single-record table, display-width alignment); the one legacy test that asserted duplicate stream stacking updated to the corrected contract. Full suite green (2119 tests, 0 failures). -
2026-07-18 —
ModuleReloadercorrectness pass: real dependency ordering (topological sort over observed imports),dependencies_first/lastpositioning fixed, dependency packages expand to their submodules, and a broken module no longer aborts the reload (newReloadReport, root:ptk.ReloadReport). Four defects fixed. (1) The global deepest-first sort clobbered caller-specified ordering: modules were sorted by dot-count across the whole run, so a shallowdependencies_firstref (e.g. tentacle's Blender reload passing("pythontk", "blendertk", "uitk")— all depth 0) sorted after every deeper submodule of the target, the exact inverse of what the parameter promises. Ordering is now per-group —dependencies_first→ target (+ submodules) →dependencies_last— anddependencies_lastgenuinely runs last (previously the depth sort slotted it before the target's root__init__; the ordering test had enshrined the artifact and now pins the corrected sequence). (2) Depth was a heuristic, not a dependency order: within each group, modules are now topologically sorted over their observed import edges — submodules bound byfrom . import xplus the__module__of imported classes/functions (restricted to modules/classes/functions so arbitrary objects'__getattr__is never triggered) — sofrom .provider import thingreloadsproviderbefore its consumer regardless of name or depth; cycles fall back to the old deepest-first order, which is whatmax_passes(unchanged, default 2) remains for. (3) Dependency refs never expanded: a package named independencies_first/lastreloaded only its root__init__; wheninclude_submodulesis True the ref now expands like the target does (honoringpredicate/exclude_modules, andimport_missing=Falsestill confines expansion to session-loaded modules) — tentacle's documented "reloads the monorepo packages in dependency order" now actually happens. An unresolvable dependency ref is skipped and recorded instead of raising mid-run. (4) One broken module aborted the whole reload: onlyImportErrorwas caught aroundimportlib.reload, so a syntax error in a single edited file propagated and left the session half-reloaded — the worst outcome for a hot-reload tool. Any exception is now contained (always printed — previously it raised, so silence would be an observability regression), remaining modules still reload, and the failure lands on the returnedReloadReport— aList[ModuleType]subclass (fully backward compatible: existing callerslen()it and iterate it) carrying.failed((name, exception)pairs),.skipped, and.ok. Also hardened: submodule discovery withimport_missing=Trueno longer crashes on an unimportable module (e.g. another DCC's slot package — caught, recorded as skipped), andpkgutil.walk_packagesgets anonerrorswallow for the same reason;fnmatchimport hoisted to module scope. Self-critique refinements (the first TDD'd red-first): a module that failed pass 1 but recovered on pass 2 — the exact scenariomax_passesexists for — landed in neither the success list nor.failed(success-append was gated on pass 1); it now appears in the success list on its first clean reload. Also: an identical failure no longer prints once per pass,KeyboardInterrupt/SystemExitre-raise via an explicit except clause instead of an isinstance branch,.skippeddedupes across passes, and the cross-block name-union is hoisted out of the dedup comprehension.test_module_reloader.py+5 (shallow-dep-first regression, provider-before-consumer topological pin, dependency-package expansion, failure containment incl. sibling-still-reloads, second-pass recovery accounting) and the ordering pin updated — 12 tests. Full suite green (2092 tests, 0 failures). -
2026-07-18 — Hierarchy-utils overhaul: new
HierarchyPath(root:ptk.HierarchyPath) is the single home for delimited-path string primitives; analyzer move-detection made deterministic;HierarchyDiffgains a bridge from analyzer records. The package's core value — namespace cleaning, split/join, normalization — was locked behind_-private staticmethods, duplicated verbatim inside the package (HierarchyIndexer._clean_namespace≡HierarchyMatching._clean_namespace), re-implemented downstream (mayatk hierarchy-sync'sclean_hierarchy_path/get_clean_node_name_from_string), and poked as privates by mayatk'stree_utils— the textbook cost of keeping useful primitives private. Newhierarchy_path.py::HierarchyPathowns them publicly (clean_namespace,split/join,strip_namespaces,normalize,leaf/root/parent/depth,tail,ends_with— pure, separator-parameterized,str.split-faithful on absolute paths); indexer/matching/analyzer delegate, and the old privates stay as deprecated shims (released mayatk builds call them; its tree code now uses the public names, and its three duplicated helpers now delegate upstream with call sites unchanged). Determinism fix (TDD'd red-first):detect_moved_itemswas first-missing-wins over set-hash iteration order — with two missing items contesting one extra, the winner was hash-order-dependent and could be the worse match; it now scores all pairs and assigns one-to-one, best similarity first with path-text tiebreaks, andanalyze_hierarchy_differencesemits sorted output for the same reason. Honest signatures:compare_path_sets/analyze_hierarchy_differencesdropped their never-usedpath_separatorparams (no ecosystem caller passes them).multi_strategy_matchswapped its if/elif chain for a strategy registry that also accepts callables(source_items, target_items) -> matches— custom strategies without editing the module — and exposes the previously hardcodedtail_components;get_path_components_indexgained thenamespace_separatorits siblings already had. Dead code removed: unreachable stdlib-difflibImportError fallbacks, the_exact_name_matchfallback they guarded, and the unused_path_ends_with(its behavior lives on as the publicHierarchyPath.ends_with).HierarchyDiffis now a dataclass (kwargs constructor, per-instance defaults) with a newmodifiedfield wired through every aggregate (is_valid/has_differences/total_issues/summary/dict/merge/clear; legacy JSON without the key loads cleanly) and — the composition piece —HierarchyDiff.from_differences(records): MISSING/EXTRA/MODIFIED route to their lists; a MOVED record classifies asrenamed(same parent) vsreparented(parent changed — a renaming move counts as reparented), keeps its pairing provenance infuzzy_matches, and consumes the MISSING/EXTRA entries it supersedes. The two diff models finally compose instead of competing. Root surface:HierarchyPathregistered, the previously unexportedDifferenceTypeadded, and the whole hierarchy family joined__all__(star-import parity). Tests: indexer and matching gained their first coverage; hierarchy tests 33 → 69, split one-file-per-module (test_hierarchy_path/_indexer/_matchingnew;test_hierarchy_utilsback to analyzer-only) — path-primitive edges, index/strategy behavior, determinism pins, dataclass semantics, bridge routing, legacy-JSON load. Full suite green (2088 tests, 0 failures). Release note: publish pythontk before mayatk (its hierarchy-sync now importsHierarchyPath). -
2026-07-18 — Textures engine improvement pass: an ORM data-loss bug fixed, the map-type taxonomy opened for runtime registration (
MapRegistry.register), and SSoT/DRY cleanups across the registry and handlers. (1) ORM AO-channel data loss (TDD'd red-first):ORMMapHandlerlacked the existing-packed-map passthrough its MRAO/MaskMap siblings have, so an ORM already in the inventory was re-derived through the conversion registry — and because the AO lookup runs before the ORM unpack caches its channels, the repack silently replaced the AO channel with white (verified(255,·,·)vs the source's(100,·,·)); an existing ORM now passes through verbatim. (2) NEWMapRegistry.register(map_type, overwrite=False): the factory's plug-in story (custom handlers + conversions,examples/texture_factory_extensibility_example.py) was incomplete — a conversion whose source type wasn't built in could never fire throughprepare_maps, because unresolvable files are dropped at inventory build and the registry had no registration API (nor cache invalidation: its derived views — sorted alias candidates, resolve memo, suffix-strip pattern, precedence rules, now also the map-types table and the longest-first alias list — cache forever). Registration invalidates all derived caches centrally (_invalidate_caches; also fixesget_precedence_ruleswriting its cache to the singleton instance, where it would have shadowed invalidation), andMapFactory.map_types/passthrough_maps/packed_grayscale_maps/map_fallbacksbecame liveClassPropertyviews over the registry instead of import-time snapshots, so a registered type is honored everywhere: filename resolution, base-name suffix stripping, inventory build, passthrough, mask scaling. The example now registers itsCurvaturetype (previously its Curvature→AO conversion was unreachable dead code);register_handleris now idempotent (module re-registration no longer duplicates the handler in the pipeline);MapTypejoinsMapRegistryon the lazy root (ptk.MapType) so registration callers stay off internal paths. (3) Godot preset taxonomy fix:WF.GODOT's description has always said "Uses ORM and OpenGL Normals" but the ORM map type'sworkflowsomitted it, so the preset shippedorm_map=False— the Godot preset now actually packs ORM (the only preset-output change; verified by before/after diff of every preset). (4) SSoT/DRY:get_workflow_presetsnow derives packed-map flags from each map'sconfig_key— the field documented as the SSoT for exactly this — instead of name inference plus a hardcoded MSAO special case (output byte-identical, per the same diff); the pack gate (pack/legacyconvert) shared by four handlers is nowWorkflowHandler.packing_enabled; the smoothness/roughness inversion-tracking block copy-pasted across MaskMap/MetallicSmoothness/MRAO handlers collapsed intoTextureProcessor.resolve_smoothness_channel/resolve_roughness_channel(verbatim extractions); the MRAO/MSAO/Metallic_Smoothness passthroughs switched from membership to the truthiness convention the processor already documents;resolve_configno longer leaks the consumed"preset"key into the resolved config;MapType.mode/default_backgroundannotations corrected toOptional(MSAO/MRAO shipmode=Noneby design). Tests: newtest_map_registry_register.py(7 — resolution/suffix/live-view pickup, cache invalidation incl. a pre-registration cached miss, duplicate/overwrite/type guards, the root export, and the E2E chain: unregistered file ignored byprepare_maps→ registered file passes through → custom conversion fires from the registered source) +test_handlers.pyORM passthrough regression (+1). Self-critique refinements (both reload-safety holes TDD-pinned): re-registering an identicalMapTypeis a no-op instead of raising — module-levelregister()calls survive the reload cycles DCC tooling lives by, while a conflicting definition still raises withoutoverwrite;register_handler's idempotence now matches by module+qualname and replaces in place, since a reload produces a new class object that identity-dedup would have duplicated;Mask's redundant self-alias dropped (the canonical name is always a resolution candidate). Full suite green (2087 tests, 0 failures); docs sweep clean. -
2026-07-18 — Shots engine quality pass: duplication removed across the model / planner / manifest layers; new
behaviors.phase_durationsprimitive. No behavior change. The behavior-template phase walk (summingin/outblock durations across attributes) existed twice — inline inbehaviors.compute_durationand again in the engine'sresolve_duration— and is now one public primitive,phase_durations(tmpl) -> (in_total, out_total), both callers rewired (also hardenscompute_durationagainst anullduration in a template, which previously summedNoneinto a float).ShotManifest._execute_plan's locked/skipped/patched branches each hand-rolled the same reposition + metadata/description diff (~30 duplicated lines); they now share_reposition_kwargs/_content_kwargsin a single unified branch (locked still position-only, patched still the only one merging objects + pinning), and the no-op_find_shotwrapper overstore.shot_by_idis gone (no callers ecosystem-wide).range_resolver.resolve_rangesduplicated the use_default audio-vs-uniform sizing policy in its placement pass and its end-resolution pass — now one_step_durationclosure.ShotStore:batch_updatere-implemented the guarded listener loop_notifyalready owns (now delegates),active()'s two create-fresh branches collapsed to one, andis_gap_locked's wrongstrtype hints corrected toint(shot IDs).shot_detection: dead unreachableif not boundariesguard removed;manifest_model._resolve_columns:_findnow composes_find_optionalinstead of repeating its scan;assess()'s audio branch dropped a deadif existsconditional (that branch only runs whenexistsis already true).test_shots_manifest_core.py+1 pins the new primitive (multi-attribute summation, empty template,Noneduration). Full suite green, registry regenerated (phase_durationsindexed;is_gap_lockedsignature change recorded). -
2026-07-18 —
ExecutionMonitorreliability pass: the long-execution dialog re-arms per invocation, Esc-cancel now lasts the whole execution, andindicatoraccepts a GIF path. Two contract bugs fixed (both TDD'd red-first): (1)execution_monitor's once-per-run dialog guard lived in the decorator-factory scope, so after the dialog appeared for one call, every later call of the same decorated function ran dialog-less for the life of the session — uitk's@Cancelableslots would prompt once and never again; the guard now re-arms at each invocation. (2) Withallow_escape_cancel=Trueand no repeat interval, the monitor thread exited right after the first callback and took the Esc poll with it — the documented "press-and-hold Esc at any time" promise silently ended at the threshold; the thread now keeps the Esc watch alive until the function returns (the wait helper reports why it woke — finished / Esc-abort / timeout — so an abort still sends exactly one interrupt instead of re-interrupting cleanup while Esc is held). Also: theindicator: bool|strtype its signature always declared is now real — a string is a path to an animated GIF (absolute, or relative to the package, e.g."task_indicator.gif") shown via the previously-orphaned_gif_viewer.py, with fallback to the canvas spinner when the file is missing; the heartbeat writer'sstop()joins the thread before deleting the heartbeat file (a still-running writer could recreate it right after cleanup); the twice-copy-pasted callback-result dispatch collapsed into_handle_callback_result, and the thrice-duplicated hidden-subprocess boilerplate into_hidden_startupinfo/_helper_script_path; dead_start_dialog_process(no callers workspace-wide) removed. Public API unchanged.test_execution_monitor.py+5 (dialog re-arm, Esc-after-callback, heartbeat join, GIF indicator argv, bad-GIF fallback) — 49 tests. -
2026-07-18 —
VidUtils.compress_video: image-sequence inputs made first-class (start_number+ audio muxing) and an output-option placement fix; newVidUtils.get_sequence_start_number. ffmpeg's image2 demuxer only auto-detects sequence start numbers in the 0–4 range, so encoding a sequence that begins at any other frame (e.g. a Maya playblast of frames 101–150) failed with "No such file or directory".compress_videonow resolves the first frame on disk (newget_sequence_start_number,%d/%0Nd-aware) or takes an explicitstart_number, emitted as an input option before-i. Newaudio_filepath/audio_offsetmux an audio track into the output (explicit-map 0:v:0/1:a:0, AAC,-af apad+-shortest— bare-shortesttruncated the video to a shorter audio track, verified 12→6 frames; positive offset delays via-itsoffset, negative skips in via-ss). Bug fixed while there: extra**ffmpeg_optionswere appended after the output path, where ffmpeg parses them as a second (invalid) output — so options likemovflagsnever applied; they now precede the output path. Driven by the mayatk playblast-exporter overhaul (its MP4/MOV targets encode from a PNG capture with real scene frame numbers — publish pythontk first). Tests:test_vid.py::CompressVideoSequenceTest(+5), gated on a managed-install-awareFFMPEG_AVAILABLE(bareshutil.whichunder-reports on machines using the AppInstaller ffmpeg). Full suite 2036/0. -
2026-07-18 (later still) — Workspace vocabulary:
RULE_NICE_NAMES+ a wrong rule key fixed in the default template (cacheFile→fileCache).RULE_NICE_NAMESis Maya's own Project Window display vocabulary (rule key → "nice name", in its Primary → Secondary display order) — data-only, driving blendertk's Workspace Editor rows/order so a shared project reads the same way in both DCCs and in Maya's native window; a new test pins that everyDEFAULT_FILE_RULESkey has a label. The template bug: it shippedcacheFile, but Maya's real file-cache rule key isfileCache(verified against Maya's default workspace.mel / its Project Window "File Cache" row) — Maya would have carried the misspelt key as an inert custom rule while its own rule kept the default. 22 tests, full suite green. -
2026-07-18 (later) — Workspace codec: rule removal (
write_workspace_mel(remove=…)/Workspace.save(remove=…)). The merge-preserving writer deliberately never deletes on its own, so an editor UI had no way to drop a rule; remove names rules whose lines are dropped (all duplicates), with rules winning on overlap and everything unmanaged still preserved verbatim. First consumer: blendertk's new Workspace Editor panel (deleted table rows →set(old) - set(new)).test_workspace.py+2 (targeted removal leaves other rules + non-rule lines intact; rules-beat-remove) — 21 tests, full suite 2025/0. -
2026-07-18 — Shared project workspaces, zero-dep:
file_utils/workspace.py(Workspace+parse_workspace_mel/write_workspace_mel). A workspace is the ecosystem's project concept — root directory + named file rules (scene→scenes,sourceImages→sourceimages, …) — and Maya'sworkspace.melmarker is its on-disk serialization: despite the extension it's a flat rule store Maya round-trips losslessly including rules it doesn't recognize, which makes it a legitimate shared format (one project folder serves Maya natively and Blender via blendertk) rather than a Maya-only file. Follows thefile_utils/usd.pyprecedent — pure-Python format primitives upstream, DCC adapters downstream. The codec's writer is merge-preserving: managed rules update their existing lines in place (duplicates collapse), new rules append after the last rule line, everything it can't parse — comments,workspace -vvariables, hand-written MEL — survives verbatim, and a content-identical result is never rewritten. TheWorkspacemodel adds semantic directory resolution (resolve_dir: rule → first existing conventional folder → default — a present rule is authoritative even when its folder doesn't exist yet),create(idempotent promotion: existing rules win, theDEFAULT_FILE_RULEStemplate only fills gaps), and discovery:find(marked dirs always count; withscene_exts, unmarked dirs directly holding scene files count too — unless nested inside a marked project, so a project'sscenes/never lists as its own workspace) andfind_containing(the walk-up mayatk'sfind_workspace_using_pathdoes, now shared). Newtest/test_workspace.py(18) pins codec semantics (Maya-authored sample with spaced rule names, MEL escapes, later-duplicate-wins, CRLF equivalence), merge preservation, model resolution chains, and discovery/suppression; the live-Maya contract (Maya opens a codec-written project, resolves a foreignblenderScenerule, and preserves it across its ownsaveWorkspacerewrite) is locked downstream inmayatk/test/test_workspace_mel.py. Full suite green (2022 tests, 0 failures). Release note: publish pythontk before blendertk (its resolver importsptk.Workspace).