From 290ab7e41fa4479d6d0a0b7cb97af68b444f6aba Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 22 Apr 2026 19:35:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#173=20=E2=80=94=20wrap=5Fjson=5Fenvelo?= =?UTF-8?q?pe()=20applied=20to=20all=2013=20clawable=20commands=20(LOOP=20?= =?UTF-8?q?CLOSED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the coverage → enforcement → documentation → alignment cycle. Every clawable command now emits the canonical JSON envelope per SCHEMAS.md: Common fields (now real in output): - timestamp (ISO 8601 UTC) - command (argv[1]) - exit_code (0/1/2) - output_format ('json') - schema_version ('1.0') 13 commands wrapped: - list-sessions, delete-session, load-session, flush-transcript - show-command, show-tool - exec-command, exec-tool, route, bootstrap - command-graph, tool-pool, bootstrap-graph Implementation: - Added wrap_json_envelope() helper in src/main.py - Wrapped all 18 JSON output paths (13 success + 5 error paths) - Applied exit_code=1 to error/not-found envelopes - Kept text mode byte-identical (backward compat preserved) Test updates: - 3 skipped common-field tests now pass automatically - 3 existing tests updated to verify common envelope fields while preserving command-specific field checks - test_list_sessions_cli_runs, test_delete_session_cli_idempotent, test_load_session_cli::test_json_mode_on_success Full suite: 179 → 182 passing (+3 activated from skipped), zero regression. Loop completion: Coverage (#167-#170) ✅ All 13 commands accept --output-format Enforcement (#171) ✅ CI blocks new commands without --output-format Documentation (#172) ✅ SCHEMAS.md defines envelope contract Alignment (#173 this) ✅ Actual output matches SCHEMAS.md contract Example output now: $ claw list-sessions --output-format json { "timestamp": "2026-04-22T10:34:12Z", "command": "list-sessions", "exit_code": 0, "output_format": "json", "schema_version": "1.0", "sessions": ["alpha", "bravo"], "count": 2 } Closes ROADMAP #173. Protocol is now documented AND real. Claws can build ONE error handler, ONE timestamp parser, ONE version check instead of 13 special cases. --- src/main.py | 69 ++++++++++++------- tests/test_json_envelope_field_consistency.py | 12 ++-- tests/test_load_session_cli.py | 18 +++-- tests/test_porting_workspace.py | 30 +++++--- 4 files changed, 84 insertions(+), 45 deletions(-) diff --git a/src/main.py b/src/main.py index e4c0504..bb3cec2 100644 --- a/src/main.py +++ b/src/main.py @@ -25,6 +25,20 @@ from .tool_pool import assemble_tool_pool from .tools import execute_tool, get_tool, get_tools, render_tool_index +def wrap_json_envelope(data: dict, command: str, exit_code: int = 0) -> dict: + """Wrap command output in canonical JSON envelope per SCHEMAS.md.""" + from datetime import datetime, timezone + now_utc = datetime.now(timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z') + return { + 'timestamp': now_utc, + 'command': command, + 'exit_code': exit_code, + 'output_format': 'json', + 'schema_version': '1.0', + **data, + } + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description='Python porting workspace for the Claude Code rewrite effort') subparsers = parser.add_subparsers(dest='command', required=True) @@ -212,7 +226,7 @@ def main(argv: list[str] | None = None) -> int: 'plugin_like': [{'name': m.name, 'source_hint': m.source_hint} for m in graph.plugin_like], 'skill_like': [{'name': m.name, 'source_hint': m.source_hint} for m in graph.skill_like], } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) else: print(graph.as_markdown()) return 0 @@ -226,7 +240,7 @@ def main(argv: list[str] | None = None) -> int: 'tool_count': len(pool.tools), 'tools': [{'name': t.name, 'source_hint': t.source_hint} for t in pool.tools], } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) else: print(pool.as_markdown()) return 0 @@ -235,7 +249,7 @@ def main(argv: list[str] | None = None) -> int: if args.output_format == 'json': import json envelope = {'stages': graph.as_markdown().split('\n'), 'note': 'bootstrap-graph is markdown-only in this version'} - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) else: print(graph.as_markdown()) return 0 @@ -281,7 +295,7 @@ def main(argv: list[str] | None = None) -> int: for m in matches ], } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) return 0 if not matches: print('No mirrored command/tool matches found.') @@ -321,7 +335,7 @@ def main(argv: list[str] | None = None) -> int: }, 'persisted_session_path': session.persisted_session_path, } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) return 0 print(session.as_markdown()) return 0 @@ -355,14 +369,15 @@ def main(argv: list[str] | None = None) -> int: path = engine.persist_session(directory) if args.output_format == 'json': import json as _json - print(_json.dumps({ + _env = { 'session_id': engine.session_id, 'path': path, 'flushed': engine.transcript_store.flushed, 'messages_count': len(engine.mutable_messages), 'input_tokens': engine.total_usage.input_tokens, 'output_tokens': engine.total_usage.output_tokens, - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command))) else: # #166: legacy text output preserved byte-for-byte for backward compat. print(path) @@ -379,7 +394,7 @@ def main(argv: list[str] | None = None) -> int: if args.output_format == 'json': import json as _json resolved_dir = str(directory) if directory else '.port_sessions' - print(_json.dumps({ + _env = { 'session_id': args.session_id, 'loaded': False, 'error': { @@ -388,7 +403,8 @@ def main(argv: list[str] | None = None) -> int: 'directory': resolved_dir, 'retryable': False, }, - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command, exit_code=1))) else: print(f'error: {exc}') return 1 @@ -398,7 +414,7 @@ def main(argv: list[str] | None = None) -> int: if args.output_format == 'json': import json as _json resolved_dir = str(directory) if directory else '.port_sessions' - print(_json.dumps({ + _env = { 'session_id': args.session_id, 'loaded': False, 'error': { @@ -407,19 +423,21 @@ def main(argv: list[str] | None = None) -> int: 'directory': resolved_dir, 'retryable': True, }, - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command, exit_code=1))) else: print(f'error: {exc}') return 1 if args.output_format == 'json': import json as _json - print(_json.dumps({ + _env = { 'session_id': session.session_id, 'loaded': True, 'messages_count': len(session.messages), 'input_tokens': session.input_tokens, 'output_tokens': session.output_tokens, - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command))) else: print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}') return 0 @@ -429,7 +447,8 @@ def main(argv: list[str] | None = None) -> int: ids = list_sessions(directory) if args.output_format == 'json': import json as _json - print(_json.dumps({'sessions': ids, 'count': len(ids)})) + _env = {'sessions': ids, 'count': len(ids)} + print(_json.dumps(wrap_json_envelope(_env, args.command))) else: if not ids: print('(no sessions)') @@ -445,7 +464,7 @@ def main(argv: list[str] | None = None) -> int: except SessionDeleteError as exc: if args.output_format == 'json': import json as _json - print(_json.dumps({ + _env = { 'session_id': args.session_id, 'deleted': False, 'error': { @@ -453,17 +472,19 @@ def main(argv: list[str] | None = None) -> int: 'message': str(exc), 'retryable': True, }, - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command, exit_code=1))) else: print(f'error: {exc}') return 1 if args.output_format == 'json': import json as _json - print(_json.dumps({ + _env = { 'session_id': args.session_id, 'deleted': deleted, 'status': 'deleted' if deleted else 'not_found', - })) + } + print(_json.dumps(wrap_json_envelope(_env, args.command))) else: if deleted: print(f'deleted: {args.session_id}') @@ -501,7 +522,7 @@ def main(argv: list[str] | None = None) -> int: 'retryable': False, }, } - print(json.dumps(error_envelope)) + print(json.dumps(wrap_json_envelope(error_envelope, args.command, exit_code=1))) else: print(f'Command not found: {args.name}') return 1 @@ -513,7 +534,7 @@ def main(argv: list[str] | None = None) -> int: 'source_hint': module.source_hint, 'responsibility': module.responsibility, } - print(json.dumps(output)) + print(json.dumps(wrap_json_envelope(output, args.command))) else: print('\n'.join([module.name, module.source_hint, module.responsibility])) return 0 @@ -531,7 +552,7 @@ def main(argv: list[str] | None = None) -> int: 'retryable': False, }, } - print(json.dumps(error_envelope)) + print(json.dumps(wrap_json_envelope(error_envelope, args.command, exit_code=1))) else: print(f'Tool not found: {args.name}') return 1 @@ -543,7 +564,7 @@ def main(argv: list[str] | None = None) -> int: 'source_hint': module.source_hint, 'responsibility': module.responsibility, } - print(json.dumps(output)) + print(json.dumps(wrap_json_envelope(output, args.command))) else: print('\n'.join([module.name, module.source_hint, module.responsibility])) return 0 @@ -571,7 +592,7 @@ def main(argv: list[str] | None = None) -> int: 'handled': True, 'message': result.message, } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) else: print(result.message) return 0 if result.handled else 1 @@ -599,7 +620,7 @@ def main(argv: list[str] | None = None) -> int: 'handled': True, 'message': result.message, } - print(json.dumps(envelope)) + print(json.dumps(wrap_json_envelope(envelope, args.command))) else: print(result.message) return 0 if result.handled else 1 diff --git a/tests/test_json_envelope_field_consistency.py b/tests/test_json_envelope_field_consistency.py index b3a6426..f4342ad 100644 --- a/tests/test_json_envelope_field_consistency.py +++ b/tests/test_json_envelope_field_consistency.py @@ -169,7 +169,6 @@ class TestJsonEnvelopeCommonFieldPrep: 13 clawable commands. Currently they document the expected contract. """ - @pytest.mark.skip(reason='Common fields not yet wrapped (ROADMAP #173)') def test_all_envelopes_include_timestamp(self) -> None: """Every clawable envelope must include ISO 8601 UTC timestamp.""" result = subprocess.run( @@ -183,12 +182,16 @@ class TestJsonEnvelopeCommonFieldPrep: # Verify ISO 8601 format (ends with Z for UTC) assert envelope['timestamp'].endswith('Z'), f'Timestamp not UTC: {envelope["timestamp"]}' - @pytest.mark.skip(reason='Common fields not yet wrapped (ROADMAP #173)') def test_all_envelopes_include_command(self) -> None: """Every envelope must echo the command name.""" - for cmd_name in ['list-sessions', 'command-graph', 'bootstrap']: + test_cases = [ + ('list-sessions', []), + ('command-graph', []), + ('bootstrap', ['hello']), + ] + for cmd_name, cmd_args in test_cases: result = subprocess.run( - [sys.executable, '-m', 'src.main', cmd_name, '--output-format', 'json'], + [sys.executable, '-m', 'src.main', cmd_name, *cmd_args, '--output-format', 'json'], cwd=Path(__file__).resolve().parent.parent, capture_output=True, text=True, @@ -196,7 +199,6 @@ class TestJsonEnvelopeCommonFieldPrep: envelope = json.loads(result.stdout) assert envelope.get('command') == cmd_name, f'{cmd_name} envelope.command mismatch' - @pytest.mark.skip(reason='Common fields not yet wrapped (ROADMAP #173)') def test_all_envelopes_include_exit_code_and_schema_version(self) -> None: """Every envelope must include exit_code and schema_version.""" result = subprocess.run( diff --git a/tests/test_load_session_cli.py b/tests/test_load_session_cli.py index c2ce0e2..527f473 100644 --- a/tests/test_load_session_cli.py +++ b/tests/test_load_session_cli.py @@ -92,13 +92,17 @@ class TestOutputFormatFlagParity: ) assert result.returncode == 0 data = json.loads(result.stdout) - assert data == { - 'session_id': 'gamma', - 'loaded': True, - 'messages_count': 2, - 'input_tokens': 5, - 'output_tokens': 7, - } + # Verify common envelope fields (SCHEMAS.md contract) + assert 'timestamp' in data + assert data['command'] == 'load-session' + assert data['exit_code'] == 0 + assert data['schema_version'] == '1.0' + # Verify command-specific fields + assert data['session_id'] == 'gamma' + assert data['loaded'] is True + assert data['messages_count'] == 2 + assert data['input_tokens'] == 5 + assert data['output_tokens'] == 7 def test_text_mode_unchanged_on_success(self, tmp_path: Path) -> None: """Legacy text output must be byte-identical for backward compat.""" diff --git a/tests/test_porting_workspace.py b/tests/test_porting_workspace.py index 0bd9670..cf78032 100644 --- a/tests/test_porting_workspace.py +++ b/tests/test_porting_workspace.py @@ -200,7 +200,13 @@ class PortingWorkspaceTests(unittest.TestCase): check=True, capture_output=True, text=True, ) data = json.loads(json_result.stdout) - self.assertEqual(data, {'sessions': ['alpha', 'bravo'], 'count': 2}) + # Verify common envelope fields (SCHEMAS.md contract) + self.assertIn('timestamp', data) + self.assertEqual(data['command'], 'list-sessions') + self.assertEqual(data['schema_version'], '1.0') + # Verify command-specific fields + self.assertEqual(data['sessions'], ['alpha', 'bravo']) + self.assertEqual(data['count'], 2) def test_delete_session_cli_idempotent(self) -> None: """#160: delete-session CLI is idempotent (not-found is exit 0, status=not_found).""" @@ -221,10 +227,16 @@ class PortingWorkspaceTests(unittest.TestCase): capture_output=True, text=True, ) self.assertEqual(first.returncode, 0) - self.assertEqual( - json.loads(first.stdout), - {'session_id': 'once', 'deleted': True, 'status': 'deleted'}, - ) + envelope_first = json.loads(first.stdout) + # Verify common envelope fields (SCHEMAS.md contract) + self.assertIn('timestamp', envelope_first) + self.assertEqual(envelope_first['command'], 'delete-session') + self.assertEqual(envelope_first['exit_code'], 0) + self.assertEqual(envelope_first['schema_version'], '1.0') + # Verify command-specific fields + self.assertEqual(envelope_first['session_id'], 'once') + self.assertEqual(envelope_first['deleted'], True) + self.assertEqual(envelope_first['status'], 'deleted') # second delete: idempotent, still exit 0 second = subprocess.run( [sys.executable, '-m', 'src.main', 'delete-session', 'once', @@ -232,10 +244,10 @@ class PortingWorkspaceTests(unittest.TestCase): capture_output=True, text=True, ) self.assertEqual(second.returncode, 0) - self.assertEqual( - json.loads(second.stdout), - {'session_id': 'once', 'deleted': False, 'status': 'not_found'}, - ) + envelope_second = json.loads(second.stdout) + self.assertEqual(envelope_second['session_id'], 'once') + self.assertEqual(envelope_second['deleted'], False) + self.assertEqual(envelope_second['status'], 'not_found') def test_delete_session_cli_partial_failure_exit_1(self) -> None: """#160: partial-failure (permission error) surfaces as exit 1 + typed JSON error."""