feat(config): T-1249 — the contract is a decorator, and now a test
Every non-zero exit names the command that would fix it, and still exits non-zero. Both halves matter; the second is the one that gets lost, because a tool that explains itself beautifully and exits 0 looks MORE correct while having silently disabled its own gate. core/errors.py holds ReachError(message, fix=) and @handle_errors. core/logging.py holds @logged, emitting through console rather than a second sink — one output path, so there is nothing to drift. core/command.py composes them, and the order is load-bearing: handle_errors wraps logged, so the logger sees the original exception. Inverted, every failure would be recorded as "SystemExit" and the log would say nothing about what went wrong while looking like it worked. core/ raises SystemExit, not typer.Exit. A service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI leaks the transport into every layer. The check router is retrofitted off its hand-rolled verdict-and-exit pattern — exactly the boilerplate this removes — and test_check_parity.py passes unchanged across the retrofit. That test predates the decorators and pins exit codes against the old script, so it is independent evidence, not a test tuned to match new behaviour. Unknown domains and unknown verbs now enumerate what exists instead of only saying no. That needed a shared group class, which collided with "no typer outside main.py and router.py" — resolved by sharpening the invariant rather than breaking it, since its purpose is that a SERVICE never knows it was called from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in service.py, schemas.py or helpers.py. The upside is that cli.domain() carries the settings that were previously per-router decisions, including the load-bearing rich_markup_mode=None that one forgetful domain could have undone. test_conformance.py makes five invariants executable, AST-based rather than grep. Scoped to the package, not the 123 legacy scripts — and deliberately so: as T-1250 moves each script into domains/, it lands inside the scope and the rules start applying automatically, so the test's reach grows with the migration. Proven to fail before being trusted: removing @command and removing a fix= each produced a failure naming the file, the line and the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,8 @@ 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', '06G1S6986SDTTQNCYCAQGKKCAW', '2026-08-20 00:42:45.655', '2026-08-20 00:42:45.655', NULL, 'b1d531df73152d05f989ff97aa5eef04', 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', '06G1S6986SDTTQNCYCAQGKKCAW', '2026-08-20 00:42:45.655', '2026-08-20 00:42:45.655', NULL, 'b1d531df73152d05f989ff97aa5eef04', 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 ('06G1S685APSPZRXSM9F62DTKGM', '06G1S6A2D22PZVQJ0S0HT91E7W', '2026-08-20 00:42:48.615', '2026-08-20 00:42:48.615', NULL, 'e02c4cc126a066706fbd79ab65351104', 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 ('06G1S685APSPZRXSM9F62DTKGM', '06G1S6A2D22PZVQJ0S0HT91E7W', '2026-08-20 00:42:48.615', '2026-08-20 00:42:48.615', NULL, 'e02c4cc126a066706fbd79ab65351104', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', '06G1S7EZ0HZSZT5HYHJ5HPBQ2G', '2026-08-20 00:42:48.741', '2026-08-20 00:42:48.741', NULL, 'a72569a21eac1ed513d718965c545f76', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', '06G1S7EZ0HZSZT5HYHJ5HPBQ2G', '2026-08-20 00:42:48.741', '2026-08-20 00:42:48.741', NULL, 'a72569a21eac1ed513d718965c545f76', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', '06G5FC15HDP9JRX3S4J0VA33QG', '2026-08-31 11:59:42.723', '2026-08-31 11:59:42.723', NULL, '401813e9d00909e90c4a03edd2cb5284', 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 ('06G5FC0EGNR11ACT6B72AJS98R', '06G5FC15HDP9JRX3S4J0VA33QG', '2026-08-31 11:59:42.859', '2026-08-31 11:59:42.859', NULL, '962cc2f14cbc0dc5658bfcfe73fe2b8b', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', '06G5FC2VMM5EVM69YBK28MYG1W', '2026-08-31 11:59:45.548', '2026-08-31 11:59:45.548', NULL, 'b91d4331a6c78991483d47b2018636ec', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', '06G5FC3YK5NDFABTF6P1FQSR28', '2026-08-31 11:59:46.100', '2026-08-31 11:59:46.100', NULL, 'a28d1af729d30064f1b919ac0aedaaf9', 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 ('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;
|
||||||
|
|||||||
@@ -1607,3 +1607,114 @@ What IS established: the JSON parses, both entries are present in permissions.al
|
|||||||
|
|
||||||
FINDING — env-prefixed calls will still prompt, and that is accepted.
|
FINDING — env-prefixed calls will still prompt, and that is accepted.
|
||||||
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', NULL, '2026-08-31 11:52:32', '2026-08-31 11:52:32.631', '2026-08-31 11:52:32.631', NULL, '73f99f6a305e4c1b8f8f2f0e01c21113', 2) ON CONFLICT(hash) DO NOTHING;
|
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', NULL, '2026-08-31 11:52:32', '2026-08-31 11:52:32.631', '2026-08-31 11:52:32.631', NULL, '73f99f6a305e4c1b8f8f2f0e01c21113', 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 ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'status', 'in_progress', 'done', NULL, '2026-08-31 11:52:48', '2026-08-31 11:52:48.374', '2026-08-31 11:52:48.374', NULL, 'b54bed8b5fa3a112c14a51ea648f8f0c', 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 ('06G1S392DEG41MRGEFSYWJWP2W', 'status', 'in_progress', 'done', NULL, '2026-08-31 11:52:51', '2026-08-31 11:52:51.341', '2026-08-31 11:52:51.341', NULL, 'd9d6d0bb42261a50367b387a264d0138', 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 ('06G1S39T92PW9JNBPWD0F4JFR0', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 11:58:57', '2026-08-31 11:58:57.338', '2026-08-31 11:58:57.338', NULL, '21a901b414eecc5980719cd016a74c31', 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 ('06G1S39T92PW9JNBPWD0F4JFR0', 'description', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
|
||||||
|
|
||||||
|
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
|
||||||
|
|
||||||
|
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.
|
||||||
|
|
||||||
|
Refined 2026-08-31 into six tasks: T-1265 core/errors.py and @handle_errors, T-1266 core/logging.py and @logged, T-1267 the composed @command decorator, T-1268 global --verbose and --no-input, T-1269 closed-set enumeration, T-1270 the conformance test. Item (4) of the description above — core/console.py as the single output path — is ALREADY DONE, delivered by T-1258 and reshaped as the JSONL event emitter by the streaming amendment; no task for it. Sequencing: T-1265 and T-1266 are unblocked and independent; T-1267 needs both; T-1268 needs T-1266; T-1269 needs T-1265; T-1270 needs T-1267. Ordering note recorded during refinement: this epic must land BEFORE T-1250 moves the tree. The check router today hand-rolls console.verdict plus raise typer.Exit at each exit — exactly the call-site discipline these decorators replace — and porting ~120 commands with that pattern means writing it 120 times and then unwinding it.', NULL, '2026-08-31 11:59:53', '2026-08-31 11:59:53.313', '2026-08-31 11:59:53.313', NULL, '526abc5eb5b9e8577639aea2644db06e', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:32:59', '2026-08-31 12:32:59.102', '2026-08-31 12:32:59.102', NULL, 'e1b2348c8b6ae4d3d49b80315308e658', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:33:43', '2026-08-31 12:33:43.759', '2026-08-31 12:33:43.759', NULL, 'b7d7808b81a8c18134ea9ea96f73214e', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:34:00', '2026-08-31 12:34:00.331', '2026-08-31 12:34:00.331', NULL, '77b70aa95dcbffa1177c794b8aa23f12', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:35:20', '2026-08-31 12:35:20.910', '2026-08-31 12:35:20.910', NULL, '63dd990dd4d64a6eff4e258121abf537', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:41:15', '2026-08-31 12:41:15.523', '2026-08-31 12:41:15.523', NULL, '08aa107dd10de78f70394b17abf05962', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 12:45:17', '2026-08-31 12:45:17.646', '2026-08-31 12:45:17.646', NULL, '557f5bf948c8f81acc2c96cf87808092', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'description', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.
|
||||||
|
|
||||||
|
DONE 2026-08-31.
|
||||||
|
|
||||||
|
core/errors.py holds ReachError(message, fix=, exit_code=) and @handle_errors. Verified end to end: `SR_REPO_ROOT=/tmp reach check client-version` now emits a structured verdict carrying its remedy and exits 1.
|
||||||
|
|
||||||
|
TWO DESIGN POINTS worth keeping.
|
||||||
|
|
||||||
|
NO TYPER IN core/. @handle_errors raises a plain SystemExit rather than typer.Exit, because core/ is transport substrate — a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Click passes SystemExit through untouched. The conformance test (T-1270) now enforces this.
|
||||||
|
|
||||||
|
exit_code CANNOT BE ZERO. ReachError coerces 0 to 1 in its constructor. A ReachError carrying exit_code=0 would be a contradiction, and it is precisely the silent-gate failure D-263 records from clide: a tool that explains itself beautifully and exits 0 has disabled its own gate, and the explanation makes it look MORE correct rather than less.
|
||||||
|
|
||||||
|
The unexpected-exception path was tested too, not just the ReachError one: an unhandled ValueError produces "unexpected ValueError: ..." with fix="re-run with --verbose for the traceback" and exit 1. Under --verbose the traceback is emitted and the fix line changes to say the traceback is the whole story and this is a bug in reach rather than in the caller''s input.
|
||||||
|
|
||||||
|
RETROFIT DONE: tooling/core/config.py no longer raises RuntimeError. It raises ReachError, and the remedy improved in the process — it now names `make reach-repoint` (which did not exist when the original message was written in T-1258) rather than spelling out a raw uv command.
|
||||||
|
|
||||||
|
Also delivered here rather than deferred: errors.unknown_choice(kind, given, accepted), the closed-set helper T-1269 needed.', NULL, '2026-08-31 13:02:16', '2026-08-31 13:02:16.094', '2026-08-31 13:02:16.094', NULL, 'a99e5b8f539e78badc27ef4795ff9e09', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'description', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/logging.py holds @logged. It is a decorator and an event kind, NOT a logging subsystem — the record goes out through core/console as a JSONL event, per the amendment, so there is exactly one output path and no second sink to drift or forget to configure.
|
||||||
|
|
||||||
|
Verified: `reach --verbose check client-version` emits {"level":"debug","command":"client_version","args":{},"duration_ms":0.4,"outcome":"ok"}. Without --verbose it emits nothing, so the push hook''s output is byte-for-byte what it was before this landed — which was the requirement, since a gate that suddenly printed a line per check would train people to stop reading gate output.
|
||||||
|
|
||||||
|
The outcome is recorded in a `finally` with the exception type as its value, so a command that raises is reported as (say) outcome="ReachError" rather than vanishing. That is what makes the composition order in T-1267 load-bearing.
|
||||||
|
|
||||||
|
ADDED BEYOND THE TICKET: argument redaction. Values under keys matching password/token/secret/api_key are replaced with *** before the record is emitted. Repo tooling handles no credentials today, so this guards nothing yet — but the cost is one frozenset and the cost of discovering it was needed later is a leaked secret in a job log that T-1264 will persist to disk under .cache/reach/jobs/.
|
||||||
|
|
||||||
|
OBSERVATION, not a defect, flagged so it is a decision rather than an accident: under --verbose the debug invocation record prints AFTER the verdict, because console.verdict is called in the command body and @logged emits from its finally. D-263 says the verdict prints "once, last, where it cannot be missed". Strictly the verdict is now second-to-last under --verbose. Left as is — the record is telemetry rather than user-facing output, and --verbose is an explicit request for telemetry. Moving the log emission before the verdict would mean not knowing the duration or the outcome yet.', NULL, '2026-08-31 13:02:27', '2026-08-31 13:02:27.495', '2026-08-31 13:02:27.495', NULL, '41de4d58522307b350aea505a9bff05d', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'description', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/command.py holds @command = handle_errors(logged(func)), plus a MARKER attribute the conformance test reads.
|
||||||
|
|
||||||
|
COMPOSITION ORDER, which the ticket flagged and which is genuinely load-bearing: handle_errors is OUTER, logged is INNER. The logger''s finally then sees the ORIGINAL exception and records its type as the outcome. Inverted, handle_errors converts everything to SystemExit first, so every failure would be logged as outcome="SystemExit" — the record says nothing about what actually went wrong while still looking like it worked, which is the worst kind of wrong.
|
||||||
|
|
||||||
|
The MARKER attribute is set explicitly on the composed wrapper rather than inferred. Unwrapping functools.wraps chains after the fact to prove "this was decorated" is brittle in exactly the way a conformance test must not be.
|
||||||
|
|
||||||
|
RETROFIT DONE. The check router no longer hand-rolls console.verdict(ok=False) followed by raise typer.Exit(1) at each failing branch — it raises ReachError with a remedy and the decorator does the rest. Both failure branches converted. The router also dropped its direct typer import entirely, now that cli.domain() builds the app (T-1269).
|
||||||
|
|
||||||
|
ACCEPTANCE MET AS SPECIFIED: test_check_parity.py passes UNCHANGED across the retrofit. That test was written in T-1262, before any of these decorators existed, and pins exit codes and message facts on three paths against the old script — so it is genuinely independent evidence that the retrofit is behaviour-preserving rather than a test adjusted to match new behaviour.', NULL, '2026-08-31 13:02:38', '2026-08-31 13:02:38.060', '2026-08-31 13:02:38.060', NULL, '9e0592655f895603829c7c8f3b77c633', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'description', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both flags live on the ROOT callback in tooling/main.py, so every domain inherits them and no router redeclares them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
--verbose lowers the console level to debug, which surfaces both progress events and the @logged invocation record, AND promotes an unexpected exception from a one-line message to a full traceback. Verified: `reach --verbose check client-version` shows the invocation record; without it, nothing.
|
||||||
|
|
||||||
|
--no-input is recorded in a new tooling/core/runtime.py. IMPORTANT: the flag alone is not the guard. can_prompt() requires BOTH the absence of --no-input AND an interactive stdin and stderr, because a prompt with no TTY does not wait — it crashes, which is the recorded tea failure in this repo. Hooks and agents pass --no-input explicitly and would still be protected by the TTY check if they forgot. Any future interactive path must call can_prompt(), never isatty alone and never the flag alone; the two guard different failures.
|
||||||
|
|
||||||
|
Nothing prompts today. runtime.py exists so the first thing that wants to has an obvious correct answer to hand, rather than inventing its own half-right isatty check.
|
||||||
|
|
||||||
|
SCOPE NOTE the ticket asked for explicitly: --no-input is about PROMPTING, not output format. Console already chooses JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, and --no-input deliberately does NOT touch that. Making it a second, conflicting way to say the same thing would leave nobody sure which one wins.
|
||||||
|
|
||||||
|
The core/ imports inside the root callback are deferred to the function body rather than module level. main.py stays a router: the callback only runs when a command is actually being executed, and test_lazy_domains confirms --help still imports nothing.', NULL, '2026-08-31 13:02:48', '2026-08-31 13:02:48.512', '2026-08-31 13:02:48.512', NULL, '107823ce477af5985b889d1115d167ec', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'description', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both levels enumerate now.
|
||||||
|
|
||||||
|
BEFORE: `reach bogus` -> "Error: No such command ''bogus''." and `reach check bogus` -> the same. Neither said what WAS available, though in both cases the accepted set was already loaded in memory.
|
||||||
|
AFTER: `reach bogus` -> "unknown domain ''bogus''" followed by "Choose one of: check". `reach check bogus` -> "unknown command ''bogus''" followed by "Choose one of: client-version". Both exit 2, Click''s usage-error convention.
|
||||||
|
|
||||||
|
Delivered: errors.unknown_choice(kind, given, accepted) as the ReachError-shaped helper for enum arguments; LazyDomainGroup.resolve_command in main.py for the domain level (free — DOMAINS is already loaded for --help); and a new shared ReachDomainGroup for the verb level.
|
||||||
|
|
||||||
|
DECISION THAT NEEDED MAKING, and it amends a D-263 invariant.
|
||||||
|
Per-domain verb enumeration needs a shared TyperGroup subclass. That collides with "no typer/click outside main.py and router.py": putting it in core/ appeared to break the rule, and the alternatives were worse — importing from main.py into routers is the wrong dependency direction, and copying the subclass into every router guarantees the copies drift so only some domains enumerate.
|
||||||
|
Resolved by sharpening the invariant rather than breaking it. Its PURPOSE is that a service never knows it was called from a CLI. So the rule is now: NEVER in service.py, schemas.py or helpers.py; transport lives in main.py, router.py, and core/cli.py. D-263 amended, and the conformance test (T-1270) enforces the sharpened form including the stricter statement about logic files.
|
||||||
|
|
||||||
|
BONUS from having core/cli.py at all: cli.domain(name, help) now builds every domain''s Typer app with the house settings applied — cls=ReachDomainGroup, rich_markup_mode=None, no_args_is_help. Those were previously per-router decisions, and rich_markup_mode=None in particular is load-bearing rather than cosmetic (it keeps rich and pygments off the import path and stops box-art help landing in hook logs). One domain forgetting it would have silently undone that. The check router is converted and dropped its direct typer import.
|
||||||
|
|
||||||
|
NOT DONE, deliberately: enum-shaped ARGUMENT values. unknown_choice exists and is ready, but no command takes an enum argument yet. Apply it as T-1250 ports domains that do.', NULL, '2026-08-31 13:03:02', '2026-08-31 13:03:02.605', '2026-08-31 13:03:02.605', NULL, 'f81f6e9822aa3458decce7004b1cb2f8', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'description', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.
|
||||||
|
|
||||||
|
DONE 2026-08-31. tooling/test_conformance.py, wired into make test-tooling. Five invariants, each executable.
|
||||||
|
|
||||||
|
(1) TRANSPORT ISOLATION — no typer/click import outside main.py, router.py and core/cli.py, with a stricter second assertion naming service.py/schemas.py/helpers.py/dependencies.py as never-permitted. AST-based, not grep: a grep for "import typer" misses "from typer.core import TyperGroup" and hits it inside a docstring.
|
||||||
|
(2) SINGLE OUTPUT PATH — no print() and no sys.stdout/stderr.write outside core/console.py.
|
||||||
|
(3) EVERY COMMAND CARRIES @command, read from the MARKER attribute rather than by unwrapping decorator chains.
|
||||||
|
(4) EVERY COMMAND HAS HELP at its own level.
|
||||||
|
(5) EVERY ReachError NAMES A REMEDY — a raise without fix= fails the suite. This is the hardest and the most valuable one, and it is why D-263 was amended to state it explicitly.
|
||||||
|
The import-graph invariant stays in test_lazy_domains.py rather than being duplicated.
|
||||||
|
|
||||||
|
SCOPE CORRECTION, found by running it. The first version walked all of tooling/ and produced 122 KB of failures — the ~123 legacy scripts use bare print() throughout. Holding code written years before this contract to that contract means 500 failures on day one and a suite nobody runs. Scoped to the PACKAGE: main.py, __init__.py, core/ and domains/.
|
||||||
|
That is not a permanent carve-out, and the shape is deliberate: as T-1250 moves each script into domains/<name>/, it lands inside the scope and the invariants start applying automatically. The test''s reach grows with the migration instead of needing to be widened by hand — and a port that leaves print() calls in place will fail the gate at the moment it is cheap to fix.
|
||||||
|
|
||||||
|
PROVEN TO FAIL on the two invariants that matter most, then restored:
|
||||||
|
- Removed @command from the check router: "[commands] `reach check client-version` is missing @command — it would run without the error contract or the invocation record".
|
||||||
|
- Removed fix= from a ReachError: "[remedy] tooling/domains/check/router.py:46 raises ReachError without fix= — an error that only says ''no'' is what D-263 exists to replace".
|
||||||
|
Both name the file, the line and the reason.
|
||||||
|
The suite also fails if NO commands are found, which would otherwise let assertions 3 and 4 pass vacuously.
|
||||||
|
|
||||||
|
make test-tooling: PASS end to end with all three reach tests wired in.', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.252', '2026-08-31 13:03:17.252', NULL, 'e7a58adb3776212fe25ea8973da8e0e6', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.396', '2026-08-31 13:03:17.396', NULL, 'e6b58df92d718d6cd52d27817e2c19ba', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.403', '2026-08-31 13:03:17.403', NULL, 'd2c48913594f621ce8522c765d8ef214', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.403', '2026-08-31 13:03:17.403', NULL, 'e6251bb085a48e4b516bf72551441aeb', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.404', '2026-08-31 13:03:17.404', NULL, '74dbc8733bcd60f59f5f0375f63c66a6', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.404', '2026-08-31 13:03:17.404', NULL, 'd8e19c53f5e59e1db8fc12fae8b11aa0', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:03:17', '2026-08-31 13:03:17.405', '2026-08-31 13:03:17.405', NULL, 'aa9af65081a7ac5d8f9387d75e8b746b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
|||||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'T-1262', '2026-08-20 00:36:41.192', '2026-08-20 00:36:41.192', NULL, 'ad11263fdf961eb2d9561e7422533346', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'T-1262', '2026-08-20 00:36:41.192', '2026-08-20 00:36:41.192', NULL, 'ad11263fdf961eb2d9561e7422533346', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'T-1263', '2026-08-20 00:41:43.428', '2026-08-20 00:41:43.428', NULL, 'b642d87918935c2bdeb9e4f1d9b8032f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'T-1263', '2026-08-20 00:41:43.428', '2026-08-20 00:41:43.428', NULL, 'b642d87918935c2bdeb9e4f1d9b8032f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'T-1264', '2026-08-20 00:42:39.895', '2026-08-20 00:42:39.895', NULL, 'da9af4d41ef21abb8a09513e4f4344d0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'T-1264', '2026-08-20 00:42:39.895', '2026-08-20 00:42:39.895', NULL, 'da9af4d41ef21abb8a09513e4f4344d0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FBZENXG6N9BWR1SK9VNMVC', 'T-1265', '2026-08-31 11:59:02.832', '2026-08-31 11:59:02.832', NULL, 'f2812849b4d78db4772d8b01cb548f7c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', 'T-1266', '2026-08-31 11:59:10.981', '2026-08-31 11:59:10.981', NULL, '5755603cbd5d0f74778643092d783dd1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC15HDP9JRX3S4J0VA33QG', 'T-1267', '2026-08-31 11:59:16.875', '2026-08-31 11:59:16.875', NULL, 'a33d2e4b8cb64060e6b8aadaab6dd6ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'T-1268', '2026-08-31 11:59:25.521', '2026-08-31 11:59:25.521', NULL, 'efca9b14f286a94fd396dfd50f3c589b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC2VMM5EVM69YBK28MYG1W', 'T-1269', '2026-08-31 11:59:30.727', '2026-08-31 11:59:30.727', NULL, '6c099247b4909cfd075343bc178594f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC3YK5NDFABTF6P1FQSR28', 'T-1270', '2026-08-31 11:59:39.673', '2026-08-31 11:59:39.673', NULL, 'db8616bb91647fcdf1a46fb8f9747be5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
|||||||
@@ -1771,3 +1771,220 @@ What IS established: the JSON parses, both entries are present in permissions.al
|
|||||||
|
|
||||||
FINDING — env-prefixed calls will still prompt, and that is accepted.
|
FINDING — env-prefixed calls will still prompt, and that is accepted.
|
||||||
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:52:32.631', NULL, '0f1abd699ed3f113681cd62d89bef251', 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;
|
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:52:32.631', NULL, '0f1abd699ed3f113681cd62d89bef251', 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 ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.
|
||||||
|
|
||||||
|
DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this.
|
||||||
|
|
||||||
|
DELIVERED — TWO entries, not one.
|
||||||
|
Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix.
|
||||||
|
The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252.
|
||||||
|
|
||||||
|
VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning.
|
||||||
|
Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt.
|
||||||
|
BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted".
|
||||||
|
WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green.
|
||||||
|
What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof.
|
||||||
|
|
||||||
|
FINDING — env-prefixed calls will still prompt, and that is accepted.
|
||||||
|
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:52:48.373', NULL, '3e9b835e22c15d9e6163c9c82a453dbb', 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 ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.
|
||||||
|
|
||||||
|
AMENDED 2026-08-20 — two corrections to the description above. (a) Item (4) calls lazy registration the mechanism the 104 ms gate budget depends on; that budget is WITHDRAWN (D-263 amendment, T-1251). Lazy registration is still mandatory, justified instead by what an eager entrypoint would really cost: scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms, about 460 ms before executing a line of its own. (b) Item (2) names the entrypoint tooling.main:app; it is tooling.main:cli — the root is a click Group subclass, not a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and keeping typer off the --help path is worth having. Refined into six tasks: T-1258 package skeleton, T-1259 entrypoint and deps, T-1260 lazy registration, T-1261 PATH guarantee, T-1262 reference domain, T-1263 permission entry.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-31 11:52:51.341', NULL, 'edfafbfa748f71587e9789b1475d5d01', 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 ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
|
||||||
|
|
||||||
|
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-31 11:58:57.338', NULL, '99383ce0ec381fd2221dd76e41b989a1', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 11:59:02.831', NULL, 'a2cb8d1c982a6d0353e757e0aebb2153', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 11:59:10.981', NULL, '7e919b74efc13c608fb2cec4c3c6eaf5', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 11:59:16.875', NULL, 'e0d55b510640001d9b1049133e8bed51', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 11:59:25.521', NULL, 'af0114f51a976c51c281e09fe2915542', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 11:59:30.726', NULL, 'f85b09d156a72f3340436ca4500dc04b', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 11:59:39.673', NULL, '952ce5c0b046413dc25d87adea956785', 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 ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
|
||||||
|
|
||||||
|
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.
|
||||||
|
|
||||||
|
Refined 2026-08-31 into six tasks: T-1265 core/errors.py and @handle_errors, T-1266 core/logging.py and @logged, T-1267 the composed @command decorator, T-1268 global --verbose and --no-input, T-1269 closed-set enumeration, T-1270 the conformance test. Item (4) of the description above — core/console.py as the single output path — is ALREADY DONE, delivered by T-1258 and reshaped as the JSONL event emitter by the streaming amendment; no task for it. Sequencing: T-1265 and T-1266 are unblocked and independent; T-1267 needs both; T-1268 needs T-1266; T-1269 needs T-1265; T-1270 needs T-1267. Ordering note recorded during refinement: this epic must land BEFORE T-1250 moves the tree. The check router today hand-rolls console.verdict plus raise typer.Exit at each exit — exactly the call-site discipline these decorators replace — and porting ~120 commands with that pattern means writing it 120 times and then unwinding it.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-31 11:59:53.312', NULL, '8f7300f8b603cfd25fed3217ffa38fa1', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 12:32:59.102', NULL, '4e69391a2169d045581174c9e13379e3', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 12:33:43.759', NULL, 'ecff81337236b765c8a8ff1a035a143e', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 12:34:00.331', NULL, 'ccc321b22d606fe734f7c441038a75da', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 12:35:20.910', NULL, '45e53497651467bc28c3972ef56bac9c', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 12:41:15.523', NULL, 'ae4ca3a31411d7070f345ce8dcfdd844', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 12:45:17.646', NULL, '96526bd1921ba2d015ed3016a04d9b88', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.
|
||||||
|
|
||||||
|
DONE 2026-08-31.
|
||||||
|
|
||||||
|
core/errors.py holds ReachError(message, fix=, exit_code=) and @handle_errors. Verified end to end: `SR_REPO_ROOT=/tmp reach check client-version` now emits a structured verdict carrying its remedy and exits 1.
|
||||||
|
|
||||||
|
TWO DESIGN POINTS worth keeping.
|
||||||
|
|
||||||
|
NO TYPER IN core/. @handle_errors raises a plain SystemExit rather than typer.Exit, because core/ is transport substrate — a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Click passes SystemExit through untouched. The conformance test (T-1270) now enforces this.
|
||||||
|
|
||||||
|
exit_code CANNOT BE ZERO. ReachError coerces 0 to 1 in its constructor. A ReachError carrying exit_code=0 would be a contradiction, and it is precisely the silent-gate failure D-263 records from clide: a tool that explains itself beautifully and exits 0 has disabled its own gate, and the explanation makes it look MORE correct rather than less.
|
||||||
|
|
||||||
|
The unexpected-exception path was tested too, not just the ReachError one: an unhandled ValueError produces "unexpected ValueError: ..." with fix="re-run with --verbose for the traceback" and exit 1. Under --verbose the traceback is emitted and the fix line changes to say the traceback is the whole story and this is a bug in reach rather than in the caller''s input.
|
||||||
|
|
||||||
|
RETROFIT DONE: tooling/core/config.py no longer raises RuntimeError. It raises ReachError, and the remedy improved in the process — it now names `make reach-repoint` (which did not exist when the original message was written in T-1258) rather than spelling out a raw uv command.
|
||||||
|
|
||||||
|
Also delivered here rather than deferred: errors.unknown_choice(kind, given, accepted), the closed-set helper T-1269 needed.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 13:02:16.094', NULL, '2274911d9c31df0760c7d988160dcb31', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/logging.py holds @logged. It is a decorator and an event kind, NOT a logging subsystem — the record goes out through core/console as a JSONL event, per the amendment, so there is exactly one output path and no second sink to drift or forget to configure.
|
||||||
|
|
||||||
|
Verified: `reach --verbose check client-version` emits {"level":"debug","command":"client_version","args":{},"duration_ms":0.4,"outcome":"ok"}. Without --verbose it emits nothing, so the push hook''s output is byte-for-byte what it was before this landed — which was the requirement, since a gate that suddenly printed a line per check would train people to stop reading gate output.
|
||||||
|
|
||||||
|
The outcome is recorded in a `finally` with the exception type as its value, so a command that raises is reported as (say) outcome="ReachError" rather than vanishing. That is what makes the composition order in T-1267 load-bearing.
|
||||||
|
|
||||||
|
ADDED BEYOND THE TICKET: argument redaction. Values under keys matching password/token/secret/api_key are replaced with *** before the record is emitted. Repo tooling handles no credentials today, so this guards nothing yet — but the cost is one frozenset and the cost of discovering it was needed later is a leaked secret in a job log that T-1264 will persist to disk under .cache/reach/jobs/.
|
||||||
|
|
||||||
|
OBSERVATION, not a defect, flagged so it is a decision rather than an accident: under --verbose the debug invocation record prints AFTER the verdict, because console.verdict is called in the command body and @logged emits from its finally. D-263 says the verdict prints "once, last, where it cannot be missed". Strictly the verdict is now second-to-last under --verbose. Left as is — the record is telemetry rather than user-facing output, and --verbose is an explicit request for telemetry. Moving the log emission before the verdict would mean not knowing the duration or the outcome yet.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 13:02:27.494', NULL, 'dc4b88ee4d4073fd84d7dcd89ad74e57', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/command.py holds @command = handle_errors(logged(func)), plus a MARKER attribute the conformance test reads.
|
||||||
|
|
||||||
|
COMPOSITION ORDER, which the ticket flagged and which is genuinely load-bearing: handle_errors is OUTER, logged is INNER. The logger''s finally then sees the ORIGINAL exception and records its type as the outcome. Inverted, handle_errors converts everything to SystemExit first, so every failure would be logged as outcome="SystemExit" — the record says nothing about what actually went wrong while still looking like it worked, which is the worst kind of wrong.
|
||||||
|
|
||||||
|
The MARKER attribute is set explicitly on the composed wrapper rather than inferred. Unwrapping functools.wraps chains after the fact to prove "this was decorated" is brittle in exactly the way a conformance test must not be.
|
||||||
|
|
||||||
|
RETROFIT DONE. The check router no longer hand-rolls console.verdict(ok=False) followed by raise typer.Exit(1) at each failing branch — it raises ReachError with a remedy and the decorator does the rest. Both failure branches converted. The router also dropped its direct typer import entirely, now that cli.domain() builds the app (T-1269).
|
||||||
|
|
||||||
|
ACCEPTANCE MET AS SPECIFIED: test_check_parity.py passes UNCHANGED across the retrofit. That test was written in T-1262, before any of these decorators existed, and pins exit codes and message facts on three paths against the old script — so it is genuinely independent evidence that the retrofit is behaviour-preserving rather than a test adjusted to match new behaviour.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 13:02:38.059', NULL, '7a7e73b7af1f00cf8431c059da9bcccc', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both flags live on the ROOT callback in tooling/main.py, so every domain inherits them and no router redeclares them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
--verbose lowers the console level to debug, which surfaces both progress events and the @logged invocation record, AND promotes an unexpected exception from a one-line message to a full traceback. Verified: `reach --verbose check client-version` shows the invocation record; without it, nothing.
|
||||||
|
|
||||||
|
--no-input is recorded in a new tooling/core/runtime.py. IMPORTANT: the flag alone is not the guard. can_prompt() requires BOTH the absence of --no-input AND an interactive stdin and stderr, because a prompt with no TTY does not wait — it crashes, which is the recorded tea failure in this repo. Hooks and agents pass --no-input explicitly and would still be protected by the TTY check if they forgot. Any future interactive path must call can_prompt(), never isatty alone and never the flag alone; the two guard different failures.
|
||||||
|
|
||||||
|
Nothing prompts today. runtime.py exists so the first thing that wants to has an obvious correct answer to hand, rather than inventing its own half-right isatty check.
|
||||||
|
|
||||||
|
SCOPE NOTE the ticket asked for explicitly: --no-input is about PROMPTING, not output format. Console already chooses JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, and --no-input deliberately does NOT touch that. Making it a second, conflicting way to say the same thing would leave nobody sure which one wins.
|
||||||
|
|
||||||
|
The core/ imports inside the root callback are deferred to the function body rather than module level. main.py stays a router: the callback only runs when a command is actually being executed, and test_lazy_domains confirms --help still imports nothing.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 13:02:48.512', NULL, 'bd9ee7bc28f099ae29190e7f2734ce30', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both levels enumerate now.
|
||||||
|
|
||||||
|
BEFORE: `reach bogus` -> "Error: No such command ''bogus''." and `reach check bogus` -> the same. Neither said what WAS available, though in both cases the accepted set was already loaded in memory.
|
||||||
|
AFTER: `reach bogus` -> "unknown domain ''bogus''" followed by "Choose one of: check". `reach check bogus` -> "unknown command ''bogus''" followed by "Choose one of: client-version". Both exit 2, Click''s usage-error convention.
|
||||||
|
|
||||||
|
Delivered: errors.unknown_choice(kind, given, accepted) as the ReachError-shaped helper for enum arguments; LazyDomainGroup.resolve_command in main.py for the domain level (free — DOMAINS is already loaded for --help); and a new shared ReachDomainGroup for the verb level.
|
||||||
|
|
||||||
|
DECISION THAT NEEDED MAKING, and it amends a D-263 invariant.
|
||||||
|
Per-domain verb enumeration needs a shared TyperGroup subclass. That collides with "no typer/click outside main.py and router.py": putting it in core/ appeared to break the rule, and the alternatives were worse — importing from main.py into routers is the wrong dependency direction, and copying the subclass into every router guarantees the copies drift so only some domains enumerate.
|
||||||
|
Resolved by sharpening the invariant rather than breaking it. Its PURPOSE is that a service never knows it was called from a CLI. So the rule is now: NEVER in service.py, schemas.py or helpers.py; transport lives in main.py, router.py, and core/cli.py. D-263 amended, and the conformance test (T-1270) enforces the sharpened form including the stricter statement about logic files.
|
||||||
|
|
||||||
|
BONUS from having core/cli.py at all: cli.domain(name, help) now builds every domain''s Typer app with the house settings applied — cls=ReachDomainGroup, rich_markup_mode=None, no_args_is_help. Those were previously per-router decisions, and rich_markup_mode=None in particular is load-bearing rather than cosmetic (it keeps rich and pygments off the import path and stops box-art help landing in hook logs). One domain forgetting it would have silently undone that. The check router is converted and dropped its direct typer import.
|
||||||
|
|
||||||
|
NOT DONE, deliberately: enum-shaped ARGUMENT values. unknown_choice exists and is ready, but no command takes an enum argument yet. Apply it as T-1250 ports domains that do.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 13:03:02.605', NULL, '06e181ec6e5ea1de0873ce67af7f72b3', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.
|
||||||
|
|
||||||
|
DONE 2026-08-31. tooling/test_conformance.py, wired into make test-tooling. Five invariants, each executable.
|
||||||
|
|
||||||
|
(1) TRANSPORT ISOLATION — no typer/click import outside main.py, router.py and core/cli.py, with a stricter second assertion naming service.py/schemas.py/helpers.py/dependencies.py as never-permitted. AST-based, not grep: a grep for "import typer" misses "from typer.core import TyperGroup" and hits it inside a docstring.
|
||||||
|
(2) SINGLE OUTPUT PATH — no print() and no sys.stdout/stderr.write outside core/console.py.
|
||||||
|
(3) EVERY COMMAND CARRIES @command, read from the MARKER attribute rather than by unwrapping decorator chains.
|
||||||
|
(4) EVERY COMMAND HAS HELP at its own level.
|
||||||
|
(5) EVERY ReachError NAMES A REMEDY — a raise without fix= fails the suite. This is the hardest and the most valuable one, and it is why D-263 was amended to state it explicitly.
|
||||||
|
The import-graph invariant stays in test_lazy_domains.py rather than being duplicated.
|
||||||
|
|
||||||
|
SCOPE CORRECTION, found by running it. The first version walked all of tooling/ and produced 122 KB of failures — the ~123 legacy scripts use bare print() throughout. Holding code written years before this contract to that contract means 500 failures on day one and a suite nobody runs. Scoped to the PACKAGE: main.py, __init__.py, core/ and domains/.
|
||||||
|
That is not a permanent carve-out, and the shape is deliberate: as T-1250 moves each script into domains/<name>/, it lands inside the scope and the invariants start applying automatically. The test''s reach grows with the migration instead of needing to be widened by hand — and a port that leaves print() calls in place will fail the gate at the moment it is cheap to fix.
|
||||||
|
|
||||||
|
PROVEN TO FAIL on the two invariants that matter most, then restored:
|
||||||
|
- Removed @command from the check router: "[commands] `reach check client-version` is missing @command — it would run without the error contract or the invocation record".
|
||||||
|
- Removed fix= from a ReachError: "[remedy] tooling/domains/check/router.py:46 raises ReachError without fix= — an error that only says ''no'' is what D-263 exists to replace".
|
||||||
|
Both name the file, the line and the reason.
|
||||||
|
The suite also fails if NO commands are found, which would otherwise let assertions 3 and 4 pass vacuously.
|
||||||
|
|
||||||
|
make test-tooling: PASS end to end with all three reach tests wired in.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 13:03:17.252', NULL, '677362ad7028e90787b900b65e964fb0', 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 ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.
|
||||||
|
|
||||||
|
DONE 2026-08-31.
|
||||||
|
|
||||||
|
core/errors.py holds ReachError(message, fix=, exit_code=) and @handle_errors. Verified end to end: `SR_REPO_ROOT=/tmp reach check client-version` now emits a structured verdict carrying its remedy and exits 1.
|
||||||
|
|
||||||
|
TWO DESIGN POINTS worth keeping.
|
||||||
|
|
||||||
|
NO TYPER IN core/. @handle_errors raises a plain SystemExit rather than typer.Exit, because core/ is transport substrate — a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Click passes SystemExit through untouched. The conformance test (T-1270) now enforces this.
|
||||||
|
|
||||||
|
exit_code CANNOT BE ZERO. ReachError coerces 0 to 1 in its constructor. A ReachError carrying exit_code=0 would be a contradiction, and it is precisely the silent-gate failure D-263 records from clide: a tool that explains itself beautifully and exits 0 has disabled its own gate, and the explanation makes it look MORE correct rather than less.
|
||||||
|
|
||||||
|
The unexpected-exception path was tested too, not just the ReachError one: an unhandled ValueError produces "unexpected ValueError: ..." with fix="re-run with --verbose for the traceback" and exit 1. Under --verbose the traceback is emitted and the fix line changes to say the traceback is the whole story and this is a bug in reach rather than in the caller''s input.
|
||||||
|
|
||||||
|
RETROFIT DONE: tooling/core/config.py no longer raises RuntimeError. It raises ReachError, and the remedy improved in the process — it now names `make reach-repoint` (which did not exist when the original message was written in T-1258) rather than spelling out a raw uv command.
|
||||||
|
|
||||||
|
Also delivered here rather than deferred: errors.unknown_choice(kind, given, accepted), the closed-set helper T-1269 needed.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 13:03:17.396', NULL, '1bfed8d486842ac5e59cef2a4993577d', 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 ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/command.py holds @command = handle_errors(logged(func)), plus a MARKER attribute the conformance test reads.
|
||||||
|
|
||||||
|
COMPOSITION ORDER, which the ticket flagged and which is genuinely load-bearing: handle_errors is OUTER, logged is INNER. The logger''s finally then sees the ORIGINAL exception and records its type as the outcome. Inverted, handle_errors converts everything to SystemExit first, so every failure would be logged as outcome="SystemExit" — the record says nothing about what actually went wrong while still looking like it worked, which is the worst kind of wrong.
|
||||||
|
|
||||||
|
The MARKER attribute is set explicitly on the composed wrapper rather than inferred. Unwrapping functools.wraps chains after the fact to prove "this was decorated" is brittle in exactly the way a conformance test must not be.
|
||||||
|
|
||||||
|
RETROFIT DONE. The check router no longer hand-rolls console.verdict(ok=False) followed by raise typer.Exit(1) at each failing branch — it raises ReachError with a remedy and the decorator does the rest. Both failure branches converted. The router also dropped its direct typer import entirely, now that cli.domain() builds the app (T-1269).
|
||||||
|
|
||||||
|
ACCEPTANCE MET AS SPECIFIED: test_check_parity.py passes UNCHANGED across the retrofit. That test was written in T-1262, before any of these decorators existed, and pins exit codes and message facts on three paths against the old script — so it is genuinely independent evidence that the retrofit is behaviour-preserving rather than a test adjusted to match new behaviour.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 13:03:17.403', NULL, '55b06a2e740ce35c2f916ad6492e98d5', 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 ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.
|
||||||
|
|
||||||
|
DONE 2026-08-31. core/logging.py holds @logged. It is a decorator and an event kind, NOT a logging subsystem — the record goes out through core/console as a JSONL event, per the amendment, so there is exactly one output path and no second sink to drift or forget to configure.
|
||||||
|
|
||||||
|
Verified: `reach --verbose check client-version` emits {"level":"debug","command":"client_version","args":{},"duration_ms":0.4,"outcome":"ok"}. Without --verbose it emits nothing, so the push hook''s output is byte-for-byte what it was before this landed — which was the requirement, since a gate that suddenly printed a line per check would train people to stop reading gate output.
|
||||||
|
|
||||||
|
The outcome is recorded in a `finally` with the exception type as its value, so a command that raises is reported as (say) outcome="ReachError" rather than vanishing. That is what makes the composition order in T-1267 load-bearing.
|
||||||
|
|
||||||
|
ADDED BEYOND THE TICKET: argument redaction. Values under keys matching password/token/secret/api_key are replaced with *** before the record is emitted. Repo tooling handles no credentials today, so this guards nothing yet — but the cost is one frozenset and the cost of discovering it was needed later is a leaked secret in a job log that T-1264 will persist to disk under .cache/reach/jobs/.
|
||||||
|
|
||||||
|
OBSERVATION, not a defect, flagged so it is a decision rather than an accident: under --verbose the debug invocation record prints AFTER the verdict, because console.verdict is called in the command body and @logged emits from its finally. D-263 says the verdict prints "once, last, where it cannot be missed". Strictly the verdict is now second-to-last under --verbose. Left as is — the record is telemetry rather than user-facing output, and --verbose is an explicit request for telemetry. Moving the log emission before the verdict would mean not knowing the duration or the outcome yet.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 13:03:17.403', NULL, 'f50ffcdc9aca956067ba7052b536736a', 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 ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both levels enumerate now.
|
||||||
|
|
||||||
|
BEFORE: `reach bogus` -> "Error: No such command ''bogus''." and `reach check bogus` -> the same. Neither said what WAS available, though in both cases the accepted set was already loaded in memory.
|
||||||
|
AFTER: `reach bogus` -> "unknown domain ''bogus''" followed by "Choose one of: check". `reach check bogus` -> "unknown command ''bogus''" followed by "Choose one of: client-version". Both exit 2, Click''s usage-error convention.
|
||||||
|
|
||||||
|
Delivered: errors.unknown_choice(kind, given, accepted) as the ReachError-shaped helper for enum arguments; LazyDomainGroup.resolve_command in main.py for the domain level (free — DOMAINS is already loaded for --help); and a new shared ReachDomainGroup for the verb level.
|
||||||
|
|
||||||
|
DECISION THAT NEEDED MAKING, and it amends a D-263 invariant.
|
||||||
|
Per-domain verb enumeration needs a shared TyperGroup subclass. That collides with "no typer/click outside main.py and router.py": putting it in core/ appeared to break the rule, and the alternatives were worse — importing from main.py into routers is the wrong dependency direction, and copying the subclass into every router guarantees the copies drift so only some domains enumerate.
|
||||||
|
Resolved by sharpening the invariant rather than breaking it. Its PURPOSE is that a service never knows it was called from a CLI. So the rule is now: NEVER in service.py, schemas.py or helpers.py; transport lives in main.py, router.py, and core/cli.py. D-263 amended, and the conformance test (T-1270) enforces the sharpened form including the stricter statement about logic files.
|
||||||
|
|
||||||
|
BONUS from having core/cli.py at all: cli.domain(name, help) now builds every domain''s Typer app with the house settings applied — cls=ReachDomainGroup, rich_markup_mode=None, no_args_is_help. Those were previously per-router decisions, and rich_markup_mode=None in particular is load-bearing rather than cosmetic (it keeps rich and pygments off the import path and stops box-art help landing in hook logs). One domain forgetting it would have silently undone that. The check router is converted and dropped its direct typer import.
|
||||||
|
|
||||||
|
NOT DONE, deliberately: enum-shaped ARGUMENT values. unknown_choice exists and is ready, but no command takes an enum argument yet. Apply it as T-1250 ports domains that do.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 13:03:17.404', NULL, '7dd8aa5c1668ea305f984d1fdb295fe0', 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 ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
DONE 2026-08-31. Both flags live on the ROOT callback in tooling/main.py, so every domain inherits them and no router redeclares them — a per-domain copy is how the two would drift apart.
|
||||||
|
|
||||||
|
--verbose lowers the console level to debug, which surfaces both progress events and the @logged invocation record, AND promotes an unexpected exception from a one-line message to a full traceback. Verified: `reach --verbose check client-version` shows the invocation record; without it, nothing.
|
||||||
|
|
||||||
|
--no-input is recorded in a new tooling/core/runtime.py. IMPORTANT: the flag alone is not the guard. can_prompt() requires BOTH the absence of --no-input AND an interactive stdin and stderr, because a prompt with no TTY does not wait — it crashes, which is the recorded tea failure in this repo. Hooks and agents pass --no-input explicitly and would still be protected by the TTY check if they forgot. Any future interactive path must call can_prompt(), never isatty alone and never the flag alone; the two guard different failures.
|
||||||
|
|
||||||
|
Nothing prompts today. runtime.py exists so the first thing that wants to has an obvious correct answer to hand, rather than inventing its own half-right isatty check.
|
||||||
|
|
||||||
|
SCOPE NOTE the ticket asked for explicitly: --no-input is about PROMPTING, not output format. Console already chooses JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, and --no-input deliberately does NOT touch that. Making it a second, conflicting way to say the same thing would leave nobody sure which one wins.
|
||||||
|
|
||||||
|
The core/ imports inside the root callback are deferred to the function body rather than module level. main.py stays a router: the callback only runs when a command is actually being executed, and test_lazy_domains confirms --help still imports nothing.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 13:03:17.404', NULL, '922c8b684276c38518f3593ee947be50', 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 ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.
|
||||||
|
|
||||||
|
DONE 2026-08-31. tooling/test_conformance.py, wired into make test-tooling. Five invariants, each executable.
|
||||||
|
|
||||||
|
(1) TRANSPORT ISOLATION — no typer/click import outside main.py, router.py and core/cli.py, with a stricter second assertion naming service.py/schemas.py/helpers.py/dependencies.py as never-permitted. AST-based, not grep: a grep for "import typer" misses "from typer.core import TyperGroup" and hits it inside a docstring.
|
||||||
|
(2) SINGLE OUTPUT PATH — no print() and no sys.stdout/stderr.write outside core/console.py.
|
||||||
|
(3) EVERY COMMAND CARRIES @command, read from the MARKER attribute rather than by unwrapping decorator chains.
|
||||||
|
(4) EVERY COMMAND HAS HELP at its own level.
|
||||||
|
(5) EVERY ReachError NAMES A REMEDY — a raise without fix= fails the suite. This is the hardest and the most valuable one, and it is why D-263 was amended to state it explicitly.
|
||||||
|
The import-graph invariant stays in test_lazy_domains.py rather than being duplicated.
|
||||||
|
|
||||||
|
SCOPE CORRECTION, found by running it. The first version walked all of tooling/ and produced 122 KB of failures — the ~123 legacy scripts use bare print() throughout. Holding code written years before this contract to that contract means 500 failures on day one and a suite nobody runs. Scoped to the PACKAGE: main.py, __init__.py, core/ and domains/.
|
||||||
|
That is not a permanent carve-out, and the shape is deliberate: as T-1250 moves each script into domains/<name>/, it lands inside the scope and the invariants start applying automatically. The test''s reach grows with the migration instead of needing to be widened by hand — and a port that leaves print() calls in place will fail the gate at the moment it is cheap to fix.
|
||||||
|
|
||||||
|
PROVEN TO FAIL on the two invariants that matter most, then restored:
|
||||||
|
- Removed @command from the check router: "[commands] `reach check client-version` is missing @command — it would run without the error contract or the invocation record".
|
||||||
|
- Removed fix= from a ReachError: "[remedy] tooling/domains/check/router.py:46 raises ReachError without fix= — an error that only says ''no'' is what D-263 exists to replace".
|
||||||
|
Both name the file, the line and the reason.
|
||||||
|
The suite also fails if NO commands are found, which would otherwise let assertions 3 and 4 pass vacuously.
|
||||||
|
|
||||||
|
make test-tooling: PASS end to end with all three reach tests wired in.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 13:03:17.405', NULL, '409ed433f3fd906ac24ca5a764108690', 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;
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ test-tooling:
|
|||||||
@mkdir -p .cache
|
@mkdir -p .cache
|
||||||
@$(VENV_PY) tooling/test_lazy_domains.py 2> .cache/test-tooling-lazy-domains.log || \
|
@$(VENV_PY) tooling/test_lazy_domains.py 2> .cache/test-tooling-lazy-domains.log || \
|
||||||
{ echo " FAIL: reach lazy registration — log follows:"; cat .cache/test-tooling-lazy-domains.log; exit 1; }
|
{ echo " FAIL: reach lazy registration — log follows:"; cat .cache/test-tooling-lazy-domains.log; exit 1; }
|
||||||
|
@echo " [test-tooling] reach D-263 conformance (T-1270)..."
|
||||||
|
@mkdir -p .cache
|
||||||
|
@$(VENV_PY) tooling/test_conformance.py 2> .cache/test-tooling-conformance.log || \
|
||||||
|
{ echo " FAIL: reach conformance — log follows:"; cat .cache/test-tooling-conformance.log; exit 1; }
|
||||||
@echo " [test-tooling] reach/check-client-version parity (T-1262)..."
|
@echo " [test-tooling] reach/check-client-version parity (T-1262)..."
|
||||||
@mkdir -p .cache
|
@mkdir -p .cache
|
||||||
@$(VENV_PY) tooling/test_check_parity.py 2> .cache/test-tooling-check-parity.log || \
|
@$(VENV_PY) tooling/test_check_parity.py 2> .cache/test-tooling-check-parity.log || \
|
||||||
|
|||||||
@@ -2598,7 +2598,7 @@ tooling/
|
|||||||
- **Pydantic is the data-shape vocabulary, and it lives in `domains/*/schemas.py`.** Available to every domain, including the gate domain. `main.py` and `core/` stay pydantic-free — not for the milliseconds, but because `main.py` is a router and `core/` is a substrate, and neither has data shapes of its own.
|
- **Pydantic is the data-shape vocabulary, and it lives in `domains/*/schemas.py`.** Available to every domain, including the gate domain. `main.py` and `core/` stay pydantic-free — not for the milliseconds, but because `main.py` is a router and `core/` is a substrate, and neither has data shapes of its own.
|
||||||
- **Typer vendors Click, so the root group is a Typer** *(found 2026-08-23 while building T-1259)*. As of **typer 0.26.0** Click is vendored: there is no top-level `click` package installed, and the docs state that "extracting the internal Click app" is no longer supported. The plan of a `click.Group` root hosting Typer sub-apps — which would have kept typer off the `--help` path — is therefore impossible; it would mean two Click implementations in one process. Lazy registration goes through the supported `typer.Typer(cls=...)` surface with a `TyperGroup` subclass instead. Two consequences worth recording: a Typer root with **no commands and no callback raises at build time** (`Could not get a command for this Typer instance`), so the empty root needs a callback; and **`rich_markup_mode=None` is load-bearing, not cosmetic** — it removes `rich` and `pygments` from the import path and takes an empty `--help` from 168 ms to 74 ms. Also: **`typer-slim` is not the answer** — deprecated as of 0.22.0 and now a shallow wrapper that installs all of typer.
|
- **Typer vendors Click, so the root group is a Typer** *(found 2026-08-23 while building T-1259)*. As of **typer 0.26.0** Click is vendored: there is no top-level `click` package installed, and the docs state that "extracting the internal Click app" is no longer supported. The plan of a `click.Group` root hosting Typer sub-apps — which would have kept typer off the `--help` path — is therefore impossible; it would mean two Click implementations in one process. Lazy registration goes through the supported `typer.Typer(cls=...)` surface with a `TyperGroup` subclass instead. Two consequences worth recording: a Typer root with **no commands and no callback raises at build time** (`Could not get a command for this Typer instance`), so the empty root needs a callback; and **`rich_markup_mode=None` is load-bearing, not cosmetic** — it removes `rich` and `pygments` from the import path and takes an empty `--help` from 168 ms to 74 ms. Also: **`typer-slim` is not the answer** — deprecated as of 0.22.0 and now a shallow wrapper that installs all of typer.
|
||||||
- **The import rule that actually matters: nothing heavy at module level in `main.py` or any `router.py`.** Measured 2026-08-20 in the repo venv: `scipy.ndimage` **275 ms**, `pydantic` 87 ms, `numpy` 72 ms, `PIL.Image` 29 ms. An entrypoint that eagerly imported the tree would pay **~460 ms before executing a line of its own** — and *that*, not pydantic, is what lazy registration exists to prevent. Heavy imports belong inside a service, or inside the function that needs them.
|
- **The import rule that actually matters: nothing heavy at module level in `main.py` or any `router.py`.** Measured 2026-08-20 in the repo venv: `scipy.ndimage` **275 ms**, `pydantic` 87 ms, `numpy` 72 ms, `PIL.Image` 29 ms. An entrypoint that eagerly imported the tree would pay **~460 ms before executing a line of its own** — and *that*, not pydantic, is what lazy registration exists to prevent. Heavy imports belong inside a service, or inside the function that needs them.
|
||||||
- **Enforced, not asked for:** no `typer`/`click` import outside `main.py` and `router.py`; no bare `print` outside `core/console.py`; no heavy import (numpy, scipy, PIL, pydantic) reachable from `main.py`; every command carries `@command`. All four are grep-shaped or import-graph-shaped, and belong in the conformance test alongside the help/failure checks. The **import-graph** assertion is the one worth writing carefully — a wall-clock assertion is flaky on a loaded machine and tells you *that* something got slow rather than *what*, whereas asserting `sys.modules` after `reach --help` names the offender directly.
|
- **Enforced, not asked for:** no `typer`/`click` import outside `main.py`, `router.py` and **`core/cli.py`** *(third file added 2026-08-31 in T-1269 — a shared domain-group class is transport by definition, and the alternative was copying the same subclass into every router, where the copies drift and only some domains end up enumerating their verbs. The invariant's purpose is that a **service** never knows it was called from a CLI, so the sharpened form is: **never** in `service.py`, `schemas.py` or `helpers.py`)*; no bare `print` outside `core/console.py`; no heavy import (numpy, scipy, PIL, pydantic) reachable from `main.py`; every command carries `@command`; **every `ReachError` names a remedy** — a raise without `fix=` fails the suite. All four are grep-shaped or import-graph-shaped, and belong in the conformance test alongside the help/failure checks. The **import-graph** assertion is the one worth writing carefully — a wall-clock assertion is flaky on a loaded machine and tells you *that* something got slow rather than *what*, whereas asserting `sys.modules` after `reach --help` names the offender directly.
|
||||||
- **Not every domain needs every file.** `schemas.py`/`dependencies.py` appear when a domain has data shapes or collaborators worth naming; `check/` may be a router and a service and nothing else. The layering is a vocabulary, not a quota — a folder of five empty modules is worse than a folder of two full ones.
|
- **Not every domain needs every file.** `schemas.py`/`dependencies.py` appear when a domain has data shapes or collaborators worth naming; `check/` may be a router and a service and nothing else. The layering is a vocabulary, not a quota — a folder of five empty modules is worse than a folder of two full ones.
|
||||||
- **`core/` is bounded on day one, because its failure mode is gradual and invisible.** It holds **only what has no domain**: config, paths, errors, console output, process launching. **The moment something in `core/` grows a service — its own logic, its own data store, its own verbs — it is a domain and it moves out.** A `core/` that accumulates services becomes a package every other package imports and nobody can change, which is the worst possible shape for the one directory that is supposed to be stable. There is no gate that catches this; it is a review question, asked every time a file is added to `core/`.
|
- **`core/` is bounded on day one, because its failure mode is gradual and invisible.** It holds **only what has no domain**: config, paths, errors, console output, process launching. **The moment something in `core/` grows a service — its own logic, its own data store, its own verbs — it is a domain and it moves out.** A `core/` that accumulates services becomes a package every other package imports and nobody can change, which is the worst possible shape for the one directory that is supposed to be stable. There is no gate that catches this; it is a review question, asked every time a file is added to `core/`.
|
||||||
- **The Blender scripts are a physically-enforced exception.** `tooling/blender` is a bash wrapper resolving flatpak/native/brew installs, and the 35 `blender_*.py` / `blender_author_*.py` files run **under Blender's own bundled interpreter** via `--background --python`, which cannot import this package. They stay standalone payload files. **`reach` fronts them; it does not absorb them** — `reach blender process-bodies` builds and executes the Blender command line. Any claim that "everything is one package" must carry this exception or it is false.
|
- **The Blender scripts are a physically-enforced exception.** `tooling/blender` is a bash wrapper resolving flatpak/native/brew installs, and the 35 `blender_*.py` / `blender_author_*.py` files run **under Blender's own bundled interpreter** via `--background --python`, which cannot import this package. They stay standalone payload files. **`reach` fronts them; it does not absorb them** — `reach blender process-bodies` builds and executes the Blender command line. Any claim that "everything is one package" must carry this exception or it is false.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""The one module in `core/` that knows about Typer.
|
||||||
|
|
||||||
|
**Why this is not a violation of the layering.** The invariant D-263 states is
|
||||||
|
that typer/click appear only in `main.py` and `router.py` — and its purpose is
|
||||||
|
that a *service* must never know it was called from a CLI. A shared group class
|
||||||
|
is transport by definition; the alternative is copying the same subclass into
|
||||||
|
every `router.py`, where the copies drift and only some domains end up
|
||||||
|
enumerating their verbs. So the invariant is refined rather than broken:
|
||||||
|
|
||||||
|
no typer/click in service.py, schemas.py or helpers.py — ever.
|
||||||
|
transport lives in main.py, router.py, and this module.
|
||||||
|
|
||||||
|
Keep that bound. If something here stops being about *transport*, it belongs
|
||||||
|
somewhere else.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from typer.core import TyperGroup
|
||||||
|
|
||||||
|
|
||||||
|
class ReachDomainGroup(TyperGroup):
|
||||||
|
"""A domain group whose unknown-verb error names the verbs that exist.
|
||||||
|
|
||||||
|
Click's default is `No such command 'x'` — which tells you that you are
|
||||||
|
wrong without telling you what would be right. That is the closed-set gap
|
||||||
|
D-263 measured in pql (an invalid status rejected without naming the six
|
||||||
|
valid ones), and the fix is nearly free: the verb list is already registered
|
||||||
|
on the group, so enumerating it costs a sort.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def resolve_command(self, ctx: typer.Context, args: list[str]):
|
||||||
|
if args and self.get_command(ctx, args[0]) is None:
|
||||||
|
listed = ", ".join(sorted(self.list_commands(ctx)))
|
||||||
|
ctx.fail(f"unknown command {args[0]!r}\n\nChoose one of: {listed}")
|
||||||
|
return super().resolve_command(ctx, args)
|
||||||
|
|
||||||
|
|
||||||
|
def domain(name: str, help: str) -> typer.Typer:
|
||||||
|
"""Build a domain's Typer app with the house settings applied.
|
||||||
|
|
||||||
|
Every domain router should use this rather than calling `typer.Typer`
|
||||||
|
directly, so the settings that are easy to forget are not per-router
|
||||||
|
decisions:
|
||||||
|
|
||||||
|
- `cls=ReachDomainGroup` so unknown verbs enumerate.
|
||||||
|
- `rich_markup_mode=None` — load-bearing, not cosmetic: it keeps `rich` and
|
||||||
|
`pygments` off the import path, and stops typer drawing box-art help even
|
||||||
|
when stdout is a pipe, which would litter hook logs.
|
||||||
|
- `no_args_is_help` so a bare `reach <domain>` says what it can do.
|
||||||
|
|
||||||
|
Note the caller still needs a `@app.callback()` on the router: Typer
|
||||||
|
collapses a single-command app into a bare command, and without the callback
|
||||||
|
`reach <domain> <verb>` fails with "unexpected extra argument".
|
||||||
|
"""
|
||||||
|
return typer.Typer(
|
||||||
|
name=name,
|
||||||
|
help=help,
|
||||||
|
cls=ReachDomainGroup,
|
||||||
|
no_args_is_help=True,
|
||||||
|
add_completion=False,
|
||||||
|
rich_markup_mode=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""`@command` — the whole contract in one decorator (D-263).
|
||||||
|
|
||||||
|
Cross-cutting concerns are decorators, never call-site discipline. The point of
|
||||||
|
composing them here is that a command author **cannot apply half the contract**:
|
||||||
|
there is no way to get logging without error handling, or to remember one and
|
||||||
|
forget the other on the 40th command of a long porting session. That failure
|
||||||
|
mode is the reason D-263 makes these decorators rather than conventions.
|
||||||
|
|
||||||
|
Usage, and it goes UNDER the Typer registration so it wraps the function Typer
|
||||||
|
will call:
|
||||||
|
|
||||||
|
@app.command("client-version")
|
||||||
|
@command
|
||||||
|
def client_version() -> None:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from tooling.core.errors import handle_errors
|
||||||
|
from tooling.core.logging import logged
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
# Set by @command and asserted by the conformance test. A marker attribute is
|
||||||
|
# used rather than inspecting the composition after the fact, because unwrapping
|
||||||
|
# functools.wraps chains to prove "this was decorated" is brittle in exactly the
|
||||||
|
# way a conformance test must not be.
|
||||||
|
MARKER = "__reach_command__"
|
||||||
|
|
||||||
|
|
||||||
|
def command(func: F) -> F:
|
||||||
|
"""Compose the invocation contract onto one command function.
|
||||||
|
|
||||||
|
**Order is load-bearing.** `handle_errors` wraps `logged`, not the reverse:
|
||||||
|
the logger's `finally` then sees the ORIGINAL exception and records its type
|
||||||
|
as the outcome. Invert them and the error handler converts everything to
|
||||||
|
`SystemExit` first, so every failure is logged as "SystemExit" and the
|
||||||
|
record says nothing about what actually went wrong — while still looking
|
||||||
|
like it worked.
|
||||||
|
"""
|
||||||
|
wrapped = handle_errors(logged(func))
|
||||||
|
setattr(wrapped, MARKER, True)
|
||||||
|
return wrapped # type: ignore[return-value]
|
||||||
+8
-10
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core.errors import ReachError
|
||||||
|
|
||||||
# The file whose presence proves a directory is the repo root. project.yaml is
|
# The file whose presence proves a directory is the repo root. project.yaml is
|
||||||
# the version source of truth (CLAUDE.md), so it is the honest sentinel: if it
|
# the version source of truth (CLAUDE.md), so it is the honest sentinel: if it
|
||||||
# is absent, everything downstream was going to fail anyway — better to say so
|
# is absent, everything downstream was going to fail anyway — better to say so
|
||||||
@@ -49,15 +51,11 @@ def path(*parts: str) -> Path:
|
|||||||
def _validated(root: Path, source: str) -> Path:
|
def _validated(root: Path, source: str) -> Path:
|
||||||
if (root / SENTINEL).is_file():
|
if (root / SENTINEL).is_file():
|
||||||
return root
|
return root
|
||||||
# Raised as RuntimeError only because core/errors.py does not exist yet;
|
raise ReachError(
|
||||||
# T-1249 converts this to ReachError(message, fix=...). The message already
|
|
||||||
# follows the contract — it names the command that fixes it.
|
|
||||||
raise RuntimeError(
|
|
||||||
f"cannot locate the repo root: {root} contains no {SENTINEL} "
|
f"cannot locate the repo root: {root} contains no {SENTINEL} "
|
||||||
f"(resolved from {source}).\n"
|
f"(resolved from {source})",
|
||||||
f"If reach was installed from a different checkout than the one you are "
|
fix=(
|
||||||
f"working in, re-point it:\n"
|
"make reach-repoint — from the checkout you want reach to follow. "
|
||||||
f" uv tool install --editable <path-to-repo>\n"
|
f"For a single command instead: {ENV_OVERRIDE}=<path-to-repo> reach ..."
|
||||||
f"To override for a single command:\n"
|
),
|
||||||
f" {ENV_OVERRIDE}=<path-to-repo> reach ..."
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ def set_level(name: str) -> None:
|
|||||||
_threshold = LEVELS[name.lower()]
|
_threshold = LEVELS[name.lower()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_verbose() -> bool:
|
||||||
|
"""True when debug-level events are being emitted.
|
||||||
|
|
||||||
|
Read by the error handler to decide whether an unexpected failure gets a
|
||||||
|
traceback or a one-liner. Verbosity is one setting, not two — a --verbose
|
||||||
|
that showed debug events but hid tracebacks would be a puzzle.
|
||||||
|
"""
|
||||||
|
return _threshold <= LEVELS["debug"]
|
||||||
|
|
||||||
|
|
||||||
def out(text: str = "") -> None:
|
def out(text: str = "") -> None:
|
||||||
"""Write to stdout — the command's actual output, never commentary."""
|
"""Write to stdout — the command's actual output, never commentary."""
|
||||||
print(text, file=sys.stdout, flush=True)
|
print(text, file=sys.stdout, flush=True)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Failures that teach, as a decorator rather than call-site discipline (D-263).
|
||||||
|
|
||||||
|
The contract: **every non-zero exit prints the command that would fix it, and
|
||||||
|
still exits non-zero.** Both halves matter, and the second is the one that gets
|
||||||
|
lost. A tool that explains itself beautifully and exits 0 has silently disabled
|
||||||
|
its own gate — and the explanation makes it look *more* correct, not less, which
|
||||||
|
is why this is a decorator with a test behind it and not a convention.
|
||||||
|
|
||||||
|
No typer or click import here. `core/` is transport substrate: a service must be
|
||||||
|
callable from a test, another service, or a future second front end, and an
|
||||||
|
exception type that only makes sense inside a CLI would leak the transport into
|
||||||
|
every layer. Exit is raised as a plain `SystemExit`, which click passes through
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
||||||
|
class ReachError(Exception):
|
||||||
|
"""A failure the caller can act on.
|
||||||
|
|
||||||
|
`fix` is not optional in spirit — it is the whole point. If you cannot name
|
||||||
|
a next command, you probably do not understand the failure well enough to
|
||||||
|
report it yet, and a message that only says "no" is the thing this exists to
|
||||||
|
replace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, fix: str | None = None, exit_code: int = 1) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.fix = fix
|
||||||
|
# Non-zero by construction. A ReachError carrying exit_code=0 would be a
|
||||||
|
# contradiction — and exactly the silent-gate failure described above.
|
||||||
|
self.exit_code = exit_code if exit_code != 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def unknown_choice(kind: str, given: str, accepted: Iterable[str]) -> ReachError:
|
||||||
|
"""Reject a value from a known finite set, naming the whole set.
|
||||||
|
|
||||||
|
Whenever the accepted values are knowable, print them. This is the specific
|
||||||
|
gap D-263 measured in pql — an invalid ticket status rejected without naming
|
||||||
|
the six valid ones — which leaves the caller grepping source to guess.
|
||||||
|
"""
|
||||||
|
options = sorted(accepted)
|
||||||
|
listed = ", ".join(options) if options else "(none available)"
|
||||||
|
return ReachError(
|
||||||
|
f"unknown {kind}: {given!r}",
|
||||||
|
fix=f"choose one of: {listed}",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_errors(func: F) -> F:
|
||||||
|
"""Render a failure through `console`, then exit with its code.
|
||||||
|
|
||||||
|
Deliberately catches nothing it cannot improve on. `SystemExit` passes
|
||||||
|
through — a decision to exit has already been made and re-reporting it would
|
||||||
|
double the output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except ReachError as exc:
|
||||||
|
# The verdict prints ONCE, LAST, after whatever the command streamed.
|
||||||
|
# A remedy emitted mid-stream at line 400 of 900 is technically
|
||||||
|
# printed and practically invisible.
|
||||||
|
console.verdict(exc.message, ok=False, fix=exc.fix)
|
||||||
|
raise SystemExit(exc.exit_code) from exc
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_report_unexpected(exc)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
return wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _report_unexpected(exc: Exception) -> None:
|
||||||
|
"""An exception nobody anticipated still exits non-zero and still says something.
|
||||||
|
|
||||||
|
The traceback goes behind `--verbose` rather than at a user who cannot act on
|
||||||
|
it; the one-line form names the flag that reveals it, so the next step is
|
||||||
|
always visible even when the failure was not foreseen.
|
||||||
|
"""
|
||||||
|
if console.is_verbose():
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
console.event(traceback.format_exc().rstrip(), level="error")
|
||||||
|
console.verdict(
|
||||||
|
f"unexpected {type(exc).__name__}: {exc}",
|
||||||
|
ok=False,
|
||||||
|
fix="the traceback above is the whole story — this is a bug in reach, not in your input",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
console.verdict(
|
||||||
|
f"unexpected {type(exc).__name__}: {exc}",
|
||||||
|
ok=False,
|
||||||
|
fix="re-run with --verbose for the traceback",
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""One structured record per invocation (D-263).
|
||||||
|
|
||||||
|
**This is not a logging subsystem.** It is a decorator and an event kind. The
|
||||||
|
record goes out through `core/console` like everything else, because D-263 names
|
||||||
|
console the single output path and two sinks would drift — in format, in
|
||||||
|
destination, in level handling — with the second always being the one nobody
|
||||||
|
remembers to configure.
|
||||||
|
|
||||||
|
Quiet by default. The record is a `debug` event, so the push hook's output looks
|
||||||
|
exactly as it does today and `--verbose` is what surfaces it. A gate that
|
||||||
|
suddenly printed a line per check would train people to stop reading gate
|
||||||
|
output, which is worse than having no record at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
# Values that should never appear in a log line even at debug level. Repo
|
||||||
|
# tooling is not handling credentials today, but the cost of the guard is one
|
||||||
|
# frozenset and the cost of discovering it was needed is a leaked secret.
|
||||||
|
_REDACT = frozenset({"password", "token", "secret", "api_key", "apikey"})
|
||||||
|
|
||||||
|
|
||||||
|
def logged(func: F) -> F:
|
||||||
|
"""Emit command, arguments, duration and outcome for one invocation.
|
||||||
|
|
||||||
|
Records the outcome in a `finally`, so a command that raises is still
|
||||||
|
reported — with the exception type as its outcome rather than silence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
started = time.monotonic()
|
||||||
|
outcome = "ok"
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except BaseException as exc:
|
||||||
|
outcome = type(exc).__name__
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
console.event(
|
||||||
|
f"{func.__name__} {outcome}",
|
||||||
|
level="debug",
|
||||||
|
command=func.__name__,
|
||||||
|
args=_safe(kwargs),
|
||||||
|
duration_ms=round((time.monotonic() - started) * 1000, 1),
|
||||||
|
outcome=outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Argument values, with anything secret-shaped replaced."""
|
||||||
|
return {
|
||||||
|
key: ("***" if key.lower() in _REDACT else value)
|
||||||
|
for key, value in kwargs.items()
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Session-level interaction state. No domain, so it lives in core (D-263).
|
||||||
|
|
||||||
|
One question, asked in one place: **may this invocation prompt?**
|
||||||
|
|
||||||
|
The answer is not just a flag, because the flag alone is not safe. A prompt with
|
||||||
|
no TTY does not wait for an answer — it *crashes*, which is the recorded `tea`
|
||||||
|
failure in this repo (interactive prompts die in Claude Code, no terminal). So
|
||||||
|
`can_prompt()` requires both an interactive stream and the absence of
|
||||||
|
`--no-input`. Hooks and agents pass `--no-input` explicitly, and would be
|
||||||
|
protected by the TTY check even if they forgot.
|
||||||
|
|
||||||
|
Nothing prompts today. This exists so that the first thing that wants to has an
|
||||||
|
obvious correct answer available, rather than inventing its own `isatty` check
|
||||||
|
that gets it half right.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_no_input = False
|
||||||
|
|
||||||
|
|
||||||
|
def set_no_input(value: bool) -> None:
|
||||||
|
"""Record the `--no-input` flag for this invocation."""
|
||||||
|
global _no_input
|
||||||
|
_no_input = value
|
||||||
|
|
||||||
|
|
||||||
|
def no_input() -> bool:
|
||||||
|
"""True when the caller has forbidden prompting."""
|
||||||
|
return _no_input
|
||||||
|
|
||||||
|
|
||||||
|
def can_prompt() -> bool:
|
||||||
|
"""True only when prompting is both permitted AND possible.
|
||||||
|
|
||||||
|
Check this, never `isatty` alone and never the flag alone — the two guard
|
||||||
|
different failures. The flag is a caller's instruction; the TTY check is
|
||||||
|
what stops a prompt from crashing a hook that forgot to pass it.
|
||||||
|
"""
|
||||||
|
if _no_input:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return sys.stdin.isatty() and sys.stderr.isatty()
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return False
|
||||||
@@ -1,29 +1,28 @@
|
|||||||
"""Transport for the `check` domain — args in, delegate, format out.
|
"""Transport for the `check` domain — args in, delegate, format out.
|
||||||
|
|
||||||
**Zero logic lives here.** Every command in this file should read as: parse,
|
**Zero logic lives here.** Every command should read as: parse, call a service,
|
||||||
call a service, turn the result into output and an exit code. If a command
|
turn the result into output. If a command grows a branch that is about the
|
||||||
grows a branch that is about the *problem* rather than about *presentation*,
|
*problem* rather than about *presentation*, that branch belongs in `service.py`.
|
||||||
that branch belongs in `service.py`.
|
|
||||||
|
Note what the commands below no longer do: no `console.verdict(..., ok=False)`
|
||||||
|
followed by `raise typer.Exit(1)` at each failing branch. They raise
|
||||||
|
`ReachError` with a remedy and `@command` does the rest — renders the verdict
|
||||||
|
once, last, and exits non-zero. That is the difference between a contract and a
|
||||||
|
habit, and it is why every command here wears `@command`.
|
||||||
|
|
||||||
The service import is deliberately at module level: by the time this module is
|
The service import is deliberately at module level: by the time this module is
|
||||||
imported at all, `reach` has already decided to run a `check` command, so there
|
imported at all, `reach` has decided to run a `check` command, so there is
|
||||||
is nothing left to defer. Laziness lives one level up, in `main.py`.
|
nothing left to defer. Laziness lives one level up, in `main.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import typer
|
from tooling.core import cli, console
|
||||||
|
from tooling.core.command import command
|
||||||
from tooling.core import console
|
from tooling.core.errors import ReachError
|
||||||
from tooling.domains.check import service
|
from tooling.domains.check import service
|
||||||
|
|
||||||
app = typer.Typer(
|
app = cli.domain("check", "Consistency gates — the checks the push hook runs.")
|
||||||
name="check",
|
|
||||||
help="Consistency gates — the checks the push hook runs.",
|
|
||||||
no_args_is_help=True,
|
|
||||||
add_completion=False,
|
|
||||||
rich_markup_mode=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
@@ -38,20 +37,19 @@ def _domain() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@app.command("client-version")
|
@app.command("client-version")
|
||||||
|
@command
|
||||||
def client_version() -> None:
|
def client_version() -> None:
|
||||||
"""Fail if the client's baked version has drifted from project.yaml."""
|
"""Fail if the client's baked version has drifted from project.yaml."""
|
||||||
result = service.client_version()
|
result = service.client_version()
|
||||||
|
|
||||||
if result.problem:
|
if result.problem:
|
||||||
console.verdict(
|
raise ReachError(
|
||||||
f"check-client-version: {result.problem}",
|
f"check-client-version: {result.problem}",
|
||||||
ok=False,
|
|
||||||
fix="check that project.yaml and client/project.godot exist and are readable",
|
fix="check that project.yaml and client/project.godot exist and are readable",
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
console.verdict(
|
raise ReachError(
|
||||||
"check-client-version: version drift\n"
|
"check-client-version: version drift\n"
|
||||||
f" project.yaml {result.yaml_version}\n"
|
f" project.yaml {result.yaml_version}\n"
|
||||||
f" client/project.godot {result.godot_version}\n"
|
f" client/project.godot {result.godot_version}\n"
|
||||||
@@ -59,12 +57,10 @@ def client_version() -> None:
|
|||||||
"This matters beyond cosmetics: the Atlas disk cache keys its\n"
|
"This matters beyond cosmetics: the Atlas disk cache keys its\n"
|
||||||
"invalidation on this version, so a stale mirror makes a shipped\n"
|
"invalidation on this version, so a stale mirror makes a shipped\n"
|
||||||
"build serve canvases generated by code it no longer runs (T-1239).",
|
"build serve canvases generated by code it no longer runs (T-1239).",
|
||||||
ok=False,
|
|
||||||
fix=(
|
fix=(
|
||||||
"set config/version in client/project.godot's [application] "
|
"set config/version in client/project.godot's [application] "
|
||||||
f"section to {result.yaml_version} — project.yaml is the source of truth"
|
f"section to {result.yaml_version} — project.yaml is the source of truth"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
console.verdict(f"check-client-version: OK — {result.yaml_version}")
|
console.verdict(f"check-client-version: OK — {result.yaml_version}")
|
||||||
|
|||||||
+34
-2
@@ -76,6 +76,16 @@ class LazyDomainGroup(TyperGroup):
|
|||||||
return _load_domain(cmd_name)
|
return _load_domain(cmd_name)
|
||||||
return super().get_command(ctx, cmd_name)
|
return super().get_command(ctx, cmd_name)
|
||||||
|
|
||||||
|
def resolve_command(self, ctx: typer.Context, args: list[str]):
|
||||||
|
# Closed-set enumeration (D-263). Click's default is "No such command
|
||||||
|
# 'x'" — which tells you that you are wrong without telling you what
|
||||||
|
# would be right, the exact gap measured in pql. The accepted set is
|
||||||
|
# sitting in DOMAINS, already loaded for --help, so naming it is free.
|
||||||
|
if args and args[0] not in DOMAINS and super().get_command(ctx, args[0]) is None:
|
||||||
|
listed = ", ".join(sorted({*DOMAINS, *super().list_commands(ctx)}))
|
||||||
|
ctx.fail(f"unknown domain {args[0]!r}\n\nChoose one of: {listed}")
|
||||||
|
return super().resolve_command(ctx, args)
|
||||||
|
|
||||||
def format_commands(self, ctx: typer.Context, formatter) -> None:
|
def format_commands(self, ctx: typer.Context, formatter) -> None:
|
||||||
# Deliberately does NOT call get_command. See the class docstring.
|
# Deliberately does NOT call get_command. See the class docstring.
|
||||||
rows = [(name, short) for name, (_target, short) in sorted(DOMAINS.items())]
|
rows = [(name, short) for name, (_target, short) in sorted(DOMAINS.items())]
|
||||||
@@ -108,5 +118,27 @@ cli = typer.Typer(
|
|||||||
|
|
||||||
|
|
||||||
@cli.callback()
|
@cli.callback()
|
||||||
def root() -> None:
|
def root(
|
||||||
"""Present so an empty root is legal — see the module docstring."""
|
verbose: bool = typer.Option(
|
||||||
|
False, "--verbose", "-v", help="Show progress events and full tracebacks."
|
||||||
|
),
|
||||||
|
no_input: bool = typer.Option(
|
||||||
|
False, "--no-input", help="Never prompt. Hooks and agents should always pass this."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Global options, declared once here so every domain inherits them.
|
||||||
|
|
||||||
|
A per-domain copy of these is how the two would drift apart — one router
|
||||||
|
growing a `--verbose` that sets a different level, or forgetting `--no-input`
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
Note `--no-input` is about PROMPTING, not output format. Console already
|
||||||
|
chooses JSONL versus rendered text from `isatty` with an `SR_OUTPUT_FORMAT`
|
||||||
|
override; making this flag a second, conflicting way to say the same thing
|
||||||
|
would leave nobody sure which one wins.
|
||||||
|
"""
|
||||||
|
from tooling.core import console, runtime
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
console.set_level("debug")
|
||||||
|
runtime.set_no_input(no_input)
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The D-263 invariants, as a test rather than a style guide (T-1270).
|
||||||
|
|
||||||
|
D-263 lists the rules that make the layering real and then says plainly that a
|
||||||
|
contract nothing checks is a style guide. This is the check.
|
||||||
|
|
||||||
|
It matters most *later*. With one domain, every rule here is obvious and nobody
|
||||||
|
would break one. Once T-1250 lands ~120 commands, nobody re-reads a decision
|
||||||
|
record before adding a verb — this is what tells them, at the moment it is cheap
|
||||||
|
to fix rather than after the pattern has been copied forty times.
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
|
||||||
|
1. Transport stays out of the logic layers. No typer/click import in any
|
||||||
|
service.py, schemas.py or helpers.py — ever. Transport lives in main.py,
|
||||||
|
router.py, and core/cli.py (the single designated transport module).
|
||||||
|
2. Nothing prints but console. No bare print()/sys.stdout.write outside
|
||||||
|
core/console.py — two output paths drift, and the second is always the one
|
||||||
|
that ends up unformatted on stdout inside a hook.
|
||||||
|
3. Every registered command carries @command, so no command can have logging
|
||||||
|
without error handling or vice versa.
|
||||||
|
4. Every command has help at its own level.
|
||||||
|
5. Every ReachError names a remedy — a `fix=` on every raise site. The hardest
|
||||||
|
to enforce and the most valuable: an error that only says "no" is the thing
|
||||||
|
D-263 exists to replace.
|
||||||
|
|
||||||
|
The import-graph invariant (nothing heavy reachable from main.py) lives in
|
||||||
|
test_lazy_domains.py rather than being duplicated here.
|
||||||
|
|
||||||
|
Run: python3 tooling/test_conformance.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
PACKAGE = REPO_ROOT / "tooling"
|
||||||
|
|
||||||
|
# Files permitted to import the CLI framework. See core/cli.py for why it is on
|
||||||
|
# the list: a shared group class is transport by definition, and the alternative
|
||||||
|
# is a copy of it in every router that drifts.
|
||||||
|
TRANSPORT_FILES = {"main.py", "router.py", "cli.py"}
|
||||||
|
LOGIC_FILES = {"service.py", "schemas.py", "helpers.py", "dependencies.py"}
|
||||||
|
FORBIDDEN_IMPORTS = {"typer", "click"}
|
||||||
|
|
||||||
|
|
||||||
|
# The invariants govern the PACKAGE, not the legacy tree. The ~123 loose scripts
|
||||||
|
# under tooling/ predate all of this and use bare print() throughout; holding
|
||||||
|
# them to a contract they were never written against would mean 500 failures on
|
||||||
|
# day one and a suite nobody runs.
|
||||||
|
#
|
||||||
|
# This is not a permanent carve-out. As T-1250 moves each script into
|
||||||
|
# domains/<name>/, it lands inside this scope and the invariants start applying
|
||||||
|
# automatically — so the test's reach grows with the migration rather than
|
||||||
|
# needing to be widened by hand.
|
||||||
|
PACKAGE_ROOTS = ("main.py", "__init__.py", "core", "domains")
|
||||||
|
|
||||||
|
|
||||||
|
def _package_files() -> list[Path]:
|
||||||
|
"""Every .py that is part of the reach package — not the legacy scripts."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for entry in PACKAGE_ROOTS:
|
||||||
|
target = PACKAGE / entry
|
||||||
|
if target.is_dir():
|
||||||
|
files.extend(target.rglob("*.py"))
|
||||||
|
elif target.is_file():
|
||||||
|
files.append(target)
|
||||||
|
return sorted(path for path in files if not path.name.startswith("test_"))
|
||||||
|
|
||||||
|
|
||||||
|
def _imports(tree: ast.AST) -> set[str]:
|
||||||
|
found: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
found.add(node.module.split(".")[0])
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def check_transport_isolation(failures: list[str]) -> None:
|
||||||
|
"""(1) No typer/click outside the designated transport files."""
|
||||||
|
for path in _package_files():
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
offending = _imports(tree) & FORBIDDEN_IMPORTS
|
||||||
|
if not offending:
|
||||||
|
continue
|
||||||
|
if path.name in TRANSPORT_FILES:
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(REPO_ROOT)
|
||||||
|
failures.append(
|
||||||
|
f"[transport] {rel} imports {', '.join(sorted(offending))} — "
|
||||||
|
f"only {', '.join(sorted(TRANSPORT_FILES))} may. "
|
||||||
|
"A service must not know it was called from a CLI."
|
||||||
|
)
|
||||||
|
if path.name in LOGIC_FILES:
|
||||||
|
failures.append(
|
||||||
|
f"[transport] {rel} is a LOGIC file — this is the invariant that "
|
||||||
|
"makes services callable from tests and from each other"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_single_output_path(failures: list[str]) -> None:
|
||||||
|
"""(2) Nothing prints but console."""
|
||||||
|
for path in _package_files():
|
||||||
|
if path.name == "console.py":
|
||||||
|
continue
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if isinstance(func, ast.Name) and func.id == "print":
|
||||||
|
failures.append(
|
||||||
|
f"[output] {path.relative_to(REPO_ROOT)}:{node.lineno} calls print() — "
|
||||||
|
"core/console.py is the single output path (D-263)"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
isinstance(func, ast.Attribute)
|
||||||
|
and func.attr == "write"
|
||||||
|
and isinstance(func.value, ast.Attribute)
|
||||||
|
and func.value.attr in {"stdout", "stderr"}
|
||||||
|
):
|
||||||
|
failures.append(
|
||||||
|
f"[output] {path.relative_to(REPO_ROOT)}:{node.lineno} writes to "
|
||||||
|
"sys.stdout/stderr directly — go through core/console.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_commands_decorated(failures: list[str]) -> None:
|
||||||
|
"""(3) and (4): every registered command carries @command and has help."""
|
||||||
|
probe = """
|
||||||
|
import json, sys
|
||||||
|
from tooling.core.command import MARKER
|
||||||
|
from tooling.main import DOMAINS, _load_domain
|
||||||
|
|
||||||
|
report = []
|
||||||
|
for name in sorted(DOMAINS):
|
||||||
|
group = _load_domain(name)
|
||||||
|
ctx = None
|
||||||
|
for verb in group.list_commands(ctx):
|
||||||
|
cmd = group.get_command(ctx, verb)
|
||||||
|
callback = getattr(cmd, "callback", None)
|
||||||
|
report.append({
|
||||||
|
"domain": name,
|
||||||
|
"verb": verb,
|
||||||
|
"decorated": bool(getattr(callback, MARKER, False)),
|
||||||
|
"help": (cmd.help or cmd.short_help or "").strip(),
|
||||||
|
})
|
||||||
|
print(json.dumps(report))
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", probe], capture_output=True, text=True, cwd=REPO_ROOT
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
failures.append(f"[commands] could not introspect the CLI:\n{result.stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
report = json.loads(result.stdout)
|
||||||
|
if not report:
|
||||||
|
failures.append(
|
||||||
|
"[commands] no commands found — every assertion here would pass vacuously"
|
||||||
|
)
|
||||||
|
for entry in report:
|
||||||
|
where = f"{entry['domain']} {entry['verb']}"
|
||||||
|
if not entry["decorated"]:
|
||||||
|
failures.append(
|
||||||
|
f"[commands] `reach {where}` is missing @command — it would run "
|
||||||
|
"without the error contract or the invocation record"
|
||||||
|
)
|
||||||
|
if not entry["help"]:
|
||||||
|
failures.append(f"[commands] `reach {where}` has no help text")
|
||||||
|
|
||||||
|
|
||||||
|
def check_errors_name_a_remedy(failures: list[str]) -> None:
|
||||||
|
"""(5) Every ReachError raise site passes fix=."""
|
||||||
|
for path in _package_files():
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Raise) or not isinstance(node.exc, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.exc.func
|
||||||
|
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||||||
|
if name != "ReachError":
|
||||||
|
continue
|
||||||
|
if not any(kw.arg == "fix" for kw in node.exc.keywords):
|
||||||
|
failures.append(
|
||||||
|
f"[remedy] {path.relative_to(REPO_ROOT)}:{node.lineno} raises "
|
||||||
|
"ReachError without fix= — an error that only says 'no' is "
|
||||||
|
"what D-263 exists to replace"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures: list[str] = []
|
||||||
|
check_transport_isolation(failures)
|
||||||
|
check_single_output_path(failures)
|
||||||
|
check_commands_decorated(failures)
|
||||||
|
check_errors_name_a_remedy(failures)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("test_conformance: FAIL", file=sys.stderr)
|
||||||
|
for failure in failures:
|
||||||
|
print(f" - {failure}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("test_conformance: OK — transport isolated, one output path, "
|
||||||
|
"every command decorated and helped, every error names a remedy")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user