docs(governance): D-263 — make and reach split by kind, streaming as a decorator
Two decisions taken before the 160-file move, because both change what the move produces. The Makefile has 84 targets and is today's front door, so "one CLI for all repo tooling" was not yet true. The split is by what a target DOES: make keeps genuine build and test orchestration, and targets that are really tooling wrappers are retired in favour of reach verbs — retired, not wrapped. A wrapper leaves two ways to invoke every tool, and then reach --help stops being the answer to "what tooling exists" because the Makefile is still a competing index. Two doors is the condition this record exists to end, so keeping both would defeat it while looking like caution. Streaming becomes a decorator rather than an API commands call. @command already wraps every invocation, and that is exactly the seam where job identity, progress correlation and detach belong: the decorator assigns the job id, tags the events, and forks on --detach. A command must not know that jobs exist. The alternative — each command opening a job and remembering to close it — is call-site discipline wearing a different hat, and it fails the same way the fortieth command into a porting session, with the failure vanishing from the log and nothing to indicate anything is missing. Logging and error handling are decorators for this reason; streaming is the third cross-cutting concern, not a special case. Consequent resequencing: T-1264 lands before the T-1250 move, so every ported command arrives already streaming. Old scripts now retire per domain as each port passes its parity test, rather than in one sweep at the end — a continuous shrink, instead of months where every tool exists twice and an edit can land in the dead copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,4 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', '06G5FC27A7ZV3ZEDYDA1R11PNC', '2026-08-31 11:59:48.345', '2026-08-31 11:59:48.345', NULL, '70cc43ac2b1719a348d0668769ef1399', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-08-31 13:24:08.442', '2026-08-31 13:24:08.442', NULL, 'e9e9cdde0d2dff4f40df155ed2aafea3', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', '06G5FZDN9YBP0MZ021N3TJCFXM', '2026-08-31 13:24:11.142', '2026-08-31 13:24:11.142', NULL, 'a6d583edb124e48fd3cd118a08084346', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', '06G1S3D0M1TQW0GMFBBPQZG3ZM', '2026-08-31 13:38:12.778', '2026-08-31 13:38:12.778', NULL, '7c1ba64b8e8f5e2a0f877b4c950c84db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
|
||||
@@ -1792,3 +1792,47 @@ PER-DOMAIN PORT TICKETS can now be filed from this map rather than guessed. Reco
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'description', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.', NULL, '2026-08-31 13:26:41', '2026-08-31 13:26:41.808', '2026-08-31 13:26:41.808', NULL, '0e257fc4279ea88af717eabc09a6bcf9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:26:59', '2026-08-31 13:26:59.127', '2026-08-31 13:26:59.127', NULL, 'd3da0e81d7c4d704e58ab121d25c707f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'description', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.
|
||||
|
||||
RESHAPED 2026-08-31 by decision. The description''s plan — 84 make targets become reach wrappers — is REPLACED. make and reach now split BY KIND, not by preference (D-263 amended): make keeps genuine build and test orchestration (cargo, Godot, the test gate, anything sequencing a build), and targets that are really tooling wrappers are RETIRED in favour of reach verbs rather than wrapped. Reason: a wrapper leaves two ways to invoke every tool, and then reach --help is no longer the answer to what tooling exists because the Makefile is still a competing index — two doors is the condition this initiative exists to end, so preserving both would defeat it while looking like caution. Where a target must survive for muscle memory it delegates to reach in one line and says so; the test is whether a reader can tell from the target alone which of the two owns the behaviour. ALSO CHANGED — retirement timing. Old scripts are now retired PER DOMAIN, the moment that domain''s port passes a parity test, rather than all at the end in one sweep here. So this epic shrinks: most retirement happens inside the per-domain port tickets under T-1250, and what remains here is the final sweep for anything not owned by a domain plus the Makefile split above. Rationale: a continuous shrink avoids months where every tool exists twice and edits can land in the dead copy.', NULL, '2026-08-31 13:37:55', '2026-08-31 13:37:55.589', '2026-08-31 13:37:55.589', NULL, '75d735b171d8b06ce9faef87c9755335', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'description', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
|
||||
|
||||
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
|
||||
|
||||
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
|
||||
|
||||
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation — that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', NULL, '2026-08-31 13:38:05', '2026-08-31 13:38:05.851', '2026-08-31 13:38:05.851', NULL, 'e1b5eddf60969d65abb6640092bf6093', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'description', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
|
||||
|
||||
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
|
||||
|
||||
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
|
||||
|
||||
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
|
||||
|
||||
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
|
||||
|
||||
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
|
||||
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
|
||||
|
||||
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.
|
||||
|
||||
REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids.', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
|
||||
|
||||
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
|
||||
|
||||
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
|
||||
|
||||
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
|
||||
|
||||
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
|
||||
|
||||
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
|
||||
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
|
||||
|
||||
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.
|
||||
|
||||
REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids.
|
||||
|
||||
SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lands BEFORE the per-domain ports, so every ported command arrives already streaming. (2) Old scripts retire PER DOMAIN, the moment that domain''s port passes a parity test — not in one sweep at T-1253. So each per-domain port ticket owns its own retirement, and the tree shrinks continuously rather than existing twice for months while edits risk landing in the dead copy. (3) Domain names as mapped are confirmed — no renames. (4) Per D-263''s amended make/reach split, a per-domain port also RETIRES that domain''s tooling make targets rather than leaving wrappers; build/test orchestration targets stay with make.', NULL, '2026-08-31 13:38:09', '2026-08-31 13:38:09.189', '2026-08-31 13:38:09.189', NULL, '3ed37cd81f1112ac323a7797c594de84', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2070,3 +2070,53 @@ PER-DOMAIN PORT TICKETS can now be filed from this map rather than guessed. Reco
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-08-31 13:26:41.808', NULL, '27621bcf97a4f061f3f89f7868004932', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The domain map — every file assigned before any file moves', 'The blocking design work for this epic, and the reason per-domain port tickets are not filed yet. Produce a single committed artefact — tooling/DOMAINS.md or similar — assigning EVERY one of the ~128 legacy .py files and ~33 extensionless executables to a domain, with its future module path and CLI verb. Until that exists, a per-domain ticket cannot state what it moves. What makes this real work rather than clerical: ~51 top-level files have an obvious home from their prefix (blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2), but 28 SINGLETON prefixes do not, and several are genuinely ambiguous. Worked examples to settle explicitly, since each sets a precedent: canvas_sources.py and generator_sources.py are REGISTRIES consumed by gates, not commands — do they become check-domain helpers, or core, or stay module-level data; schema_version.py is consumed by the DB importer and by a gate, so it is shared between two domains; tea-comment and pr-watchlist-diff are PR workflow, which is NOT in the epic''s proposed domain list at all, so either a workflow domain is added or they go to dev. Also decide: does become a domain, or do the 5 test-prefixed files stay as standalone scripts run by make (they are gate tests, not commands anyone types). Deliverable rule to apply throughout: a domain that has only a router and a service does not get empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota. Acceptance: no file unassigned, every ambiguous case carrying a one-line reason, and the verb list readable as the future output of reach --help.
|
||||
|
||||
DONE 2026-08-31. tooling/DOMAINS.md committed: 15 domains, every file and executable assigned, judgment calls carrying their reasons.
|
||||
|
||||
THREE CORRECTIONS FOUND BY COUNTING RATHER THAN READING, one of which reverses my own earlier survey.
|
||||
|
||||
(1) THE BLENDER CARVE-OUT IS 35 FILES, NOT 13 — and the T-1250 description was RIGHT while my survey on this ticket was wrong. 13 blender_*.py sit at top level; 22 more are inside garment-fit/. My survey counted only the top level and told the epic it had overstated the number. It had not.
|
||||
The consequence is not cosmetic. garment-fit/ is 23 files of which 22 are blender_author_* payloads, so it is a Blender payload directory wearing a domain''s name. `character` is therefore a MUCH smaller domain than directory sizes suggest, and T-1273''s carve-out is much larger. A per-domain ticket written from the directory listing would have been wrong about both.
|
||||
|
||||
(2) THE "28 SINGLETON PREFIXES" WERE AN ARTEFACT OF MY OWN MEASUREMENT. Splitting filenames on the first token scattered coherent families: sculpt-star-map, tune-star-map-topology and generate-star-map* are ONE group (starmap), counted as three orphans. Counting families rather than prefixes, the genuinely ambiguous set is small and is now enumerated with reasons instead of being a number.
|
||||
|
||||
(3) tooling/db/ IS MISNAMED and naming a domain after it would carry the misnomer forward. It holds the audio/image/Trellis connectors and wiki_sync.py; the actual database work is in economy-db/. Its contents split between a new `assets` domain (the tower-of-joy connectors) and `wiki`.
|
||||
|
||||
JUDGMENT CALLS SETTLED, each with a recorded reason since each sets a precedent:
|
||||
- Registries (canvas_sources.py, generator_sources.py) stay module-level data, NOT verbs. Nothing types them; putting them in reach --help would answer a question nobody has.
|
||||
- schema_version.py goes to `db` where it is DEFINED, and `check` imports it. Shared but not equally owned — that cross-domain import is what a service layer is for.
|
||||
- test_*.py do NOT become a domain. They are gate tests run by make, and `reach test ...` would imply a runner that does not exist.
|
||||
- pql-migrate/ is PROVENANCE, not tooling. Moves to tooling/archive/, excluded from discovery. Deleting destroys migration provenance; keeping it importable implies it still runs.
|
||||
- `pr` is a domain the epic did not list (tea-comment, pr-watchlist-diff, pql-board-html). Folding them into `dev` would make dev the drawer everything ambiguous goes into — the same rot D-263 warns about for core/.
|
||||
- `character`, not D-263''s `garment`: the files cover bodies, hair, GLB and Quaternius as well as clothing.
|
||||
- `atlas` IS OVERLOADED ACROSS THREE PLACES and must not be merged: top-level atlas-* executables (map data), planet-gen/atlas_*.py (terrain quality analysis), economy-db/atlas.py (systems.db index tables). Three concerns sharing a noun. Each stays with its owner.
|
||||
- economy-db/errors.py predates core/errors.py and is not the same thing. Reconcile explicitly when db is ported; a silent merge or a second ReachError with different semantics is the risk.
|
||||
|
||||
PER-DOMAIN PORT TICKETS can now be filed from this map rather than guessed. Recommended order, cheapest and most-verified first: check (4 remaining gates, already has the reference implementation and a parity test to copy), validate, godot, visual, then the large ones — planet, db, character, wiki, assets, atlas, starmap, generate, dev, pr.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:42.403', '2026-08-31 13:26:59.127', NULL, '2bbeba1b0d3aa3fddae88e8f194ed11e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E6 — Retire the old paths: make targets, docs, skills, permissions', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.
|
||||
|
||||
RESHAPED 2026-08-31 by decision. The description''s plan — 84 make targets become reach wrappers — is REPLACED. make and reach now split BY KIND, not by preference (D-263 amended): make keeps genuine build and test orchestration (cargo, Godot, the test gate, anything sequencing a build), and targets that are really tooling wrappers are RETIRED in favour of reach verbs rather than wrapped. Reason: a wrapper leaves two ways to invoke every tool, and then reach --help is no longer the answer to what tooling exists because the Makefile is still a competing index — two doors is the condition this initiative exists to end, so preserving both would defeat it while looking like caution. Where a target must survive for muscle memory it delegates to reach in one line and says so; the test is whether a reader can tell from the target alone which of the two owns the behaviour. ALSO CHANGED — retirement timing. Old scripts are now retired PER DOMAIN, the moment that domain''s port passes a parity test, rather than all at the end in one sweep here. So this epic shrinks: most retirement happens inside the per-domain port tickets under T-1250, and what remains here is the final sweep for anything not owned by a domain plus the Makefile split above. Rationale: a continuous shrink avoids months where every tool exists twice and edits can land in the dead copy.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:23.657', '2026-08-31 13:37:55.588', NULL, '909fda9e752ff283beedc13bd3c52958', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
|
||||
|
||||
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
|
||||
|
||||
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation — that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:38:05.851', NULL, '4ba2b178e4e9d406141998eddc45f4b3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
|
||||
|
||||
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
|
||||
|
||||
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
|
||||
|
||||
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
|
||||
|
||||
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
|
||||
|
||||
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
|
||||
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
|
||||
|
||||
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.
|
||||
|
||||
REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids.
|
||||
|
||||
SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lands BEFORE the per-domain ports, so every ported command arrives already streaming. (2) Old scripts retire PER DOMAIN, the moment that domain''s port passes a parity test — not in one sweep at T-1253. So each per-domain port ticket owns its own retirement, and the tree shrinks continuously rather than existing twice for months while edits risk landing in the dead copy. (3) Domain names as mapped are confirmed — no renames. (4) Per D-263''s amended make/reach split, a per-domain port also RETIRES that domain''s tooling make targets rather than leaving wrappers; build/test orchestration targets stay with make.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:38:09.189', NULL, 'f4baef3d1ff514ca3ee4823cf2c12043', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -2611,9 +2611,16 @@ tooling/
|
||||
- **Never literally interactive by default.** Any prompt is TTY-gated and suppressible with `--no-input`, which hooks pass unconditionally. `tea`'s interactive prompts *"crash in Claude Code (no TTY)"*; a helpful prompt that hangs a hook is worse than a terse exit code.
|
||||
- **This is enforced by a conformance test, not by discipline** — every registered command must have help at its own level, and every declared failure path must name a next command. A contract nothing checks is a style guide.
|
||||
|
||||
**`make` and `reach` split by kind, not by preference** *(settled 2026-08-31)*. The Makefile has 84 targets and is today's front door, so "one CLI for all repo tooling" is not true until that relationship is stated. The boundary is **what the target actually does**, not who calls it:
|
||||
|
||||
- **`make` keeps genuine build and test orchestration** — cargo, Godot, the test gate, anything that sequences a build. That is what make is for, and `reach` would be a worse version of it.
|
||||
- **Targets that are really tooling wrappers are retired in favour of `reach` verbs — retired, not wrapped.** A wrapper leaves two ways to invoke every tool, and then `reach --help` stops being the answer to "what tooling exists" because the Makefile is still a competing index. **Two doors is the condition this record exists to end**, so keeping both would defeat it while looking like caution.
|
||||
- Where a target must survive for muscle memory, it delegates to `reach` in one line and says so. The test: can a reader tell from the target alone which of the two owns the behaviour?
|
||||
|
||||
**Commands stream, they do not go quiet and return a verdict** *(added 2026-08-20)*. The gates are milliseconds; the generators are not. `make regen-db`, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors run for minutes, and the callers that matter have ceilings — an agent `Bash` call gives up at two minutes and **sends nothing**, already a recorded scar here for `git push` under the full-`cargo test` hook. Detaching alone would fix the timeout and keep the silence. Streaming fixes the thing that actually costs time: **you learn a generator is wedged at minute one instead of minute nine.**
|
||||
|
||||
- **Every command emits a stream of structured progress events as it runs.** Not a requirement to instrument everything — a command that emits nothing still works, and the gates should emit nothing. It is a requirement that the *channel exists* and is the same channel everywhere, so a long command has somewhere to say what it is doing.
|
||||
- **Streaming is delivered AS A DECORATOR, not as an API that commands call** *(settled 2026-08-31)*. `@command` already wraps every invocation, and that is exactly the seam where job identity, progress correlation and detach belong: the decorator assigns the job id, tags every event emitted during the invocation with it, and — given `--detach` — forks and returns the id immediately. **A command must not know that jobs exist.** The alternative, where each command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails the same way — the fortieth command of a long porting session forgets, and its failure vanishes from the log with nothing to indicate anything is missing. This is the same reasoning that made logging and error handling decorators; streaming is a third cross-cutting concern, not a special case.
|
||||
- **The stream is JSONL — one object per line** (`ts`, `level`, `phase`, `message`, optional `progress`), rendered human-readably at the sink. Machine-parseable and human-readable are then the **same artefact** rather than two that drift: `reach jobs log` renders it for a person, and the conformance suite asserts against it directly.
|
||||
- **The stream goes to stderr; stdout carries the command's actual output.** Same rule already stated for `@logged`, for the same reason — `reach … | jq` must keep working.
|
||||
- **`core/console.py` is therefore an event emitter, not a print wrapper.** It remains the single output path and stays stdlib-only (`json` is stdlib). This is the module every command depends on, so its shape is fixed here rather than discovered per-domain.
|
||||
|
||||
Reference in New Issue
Block a user