devtorch-cli 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devtorch_cli/main.py ADDED
@@ -0,0 +1,4090 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import types
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from devtorch_core import (
12
+ GCCRepository,
13
+ SensitivityEvent,
14
+ OmegaCapability,
15
+ L_MAX_DEFAULT,
16
+ )
17
+ from devtorch_core.serve import cmd_serve as _cmd_serve
18
+
19
+ from devtorch_cli.session_cmds import (
20
+ cmd_session_start,
21
+ cmd_session_list,
22
+ cmd_session_show,
23
+ cmd_session_plan,
24
+ cmd_session_next,
25
+ cmd_session_mark,
26
+ cmd_session_close,
27
+ cmd_session_disagree,
28
+ cmd_session_resolve,
29
+ cmd_session_disagreements,
30
+ cmd_simulate,
31
+ )
32
+ from devtorch_cli.capability_role_cmds import (
33
+ cmd_capability_role_list,
34
+ cmd_capability_role_show,
35
+ cmd_capability_role_set,
36
+ cmd_capability_role_policy,
37
+ cmd_capability_role_apply_default_policy,
38
+ )
39
+ from devtorch_cli.pre_commit_cmds import (
40
+ cmd_pre_commit_install,
41
+ cmd_pre_commit_run,
42
+ )
43
+ from devtorch_cli.pr_reporter_cmd import cmd_pr_report
44
+ from devtorch_cli.audit_cmds import build_audit_parser
45
+
46
+
47
+ def cmd_init(args: argparse.Namespace) -> int:
48
+ project_root = Path(args.path).resolve()
49
+ template_name = getattr(args, "template", None)
50
+ if template_name:
51
+ from devtorch_core.templates.engine import apply_template
52
+ try:
53
+ apply_template(project_root, template_name)
54
+ except ValueError as exc:
55
+ print(f"error: {exc}", file=sys.stderr)
56
+ return 1
57
+ print(f"Initialized DevTorch GCC store at {project_root / '.GCC'} with template '{template_name}'")
58
+ else:
59
+ repo = GCCRepository.at(project_root)
60
+ repo.init()
61
+ print(f"Initialized DevTorch GCC store at {repo.gcc_dir}")
62
+ return 0
63
+
64
+
65
+ # S27: reasoning template library
66
+
67
+
68
+ def cmd_templates_list(args: argparse.Namespace) -> int:
69
+ """List built-in and custom reasoning templates."""
70
+ from devtorch_core.templates.library import (
71
+ list_builtin_templates,
72
+ discover_custom_templates,
73
+ )
74
+
75
+ project_root = Path(args.path).resolve()
76
+ builtins = list_builtin_templates()
77
+ custom = discover_custom_templates(project_root)
78
+
79
+ if not builtins and not custom:
80
+ print("No templates found.")
81
+ return 0
82
+
83
+ print("Built-in templates:")
84
+ for t in builtins:
85
+ tags = ", ".join(t.get("tags", []))
86
+ print(f" {t['name']:<22} {t['description']}")
87
+ if tags:
88
+ print(f" tags: {tags}")
89
+
90
+ if custom:
91
+ print("\nCustom templates:")
92
+ for t in custom:
93
+ tags = ", ".join(t.get("tags", []))
94
+ print(f" {t['name']:<22} {t['description']} [{t['origin']}]")
95
+ if tags:
96
+ print(f" tags: {tags}")
97
+
98
+ return 0
99
+
100
+
101
+ def cmd_templates_apply(args: argparse.Namespace) -> int:
102
+ """Render a reasoning template with variables."""
103
+ from devtorch_core.templates.library import apply_template
104
+
105
+ variables: dict[str, str] = {}
106
+ for var in getattr(args, "variables", []) or []:
107
+ if "=" not in var:
108
+ print(f"error: --var must be KEY=VALUE, got: {var}", file=sys.stderr)
109
+ return 1
110
+ key, value = var.split("=", 1)
111
+ variables[key.strip()] = value.strip()
112
+
113
+ project_root = Path(args.path).resolve()
114
+ output_path = Path(args.output) if args.output else None
115
+
116
+ try:
117
+ out = apply_template(
118
+ args.name,
119
+ variables,
120
+ output_path=output_path,
121
+ project_root=project_root,
122
+ )
123
+ except ValueError as exc:
124
+ print(f"error: {exc}", file=sys.stderr)
125
+ return 1
126
+
127
+ print(f"Rendered template to {out}")
128
+ return 0
129
+
130
+
131
+ def cmd_templates_init(args: argparse.Namespace) -> int:
132
+ """Create the custom template extension directory and README."""
133
+ project_root = Path(args.path).resolve()
134
+ templates_dir = project_root / ".devtorch" / "templates"
135
+ templates_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ readme = templates_dir / "README.md"
138
+ content = """# Custom Reasoning Templates
139
+
140
+ Add Markdown templates to this directory to make them available via `devtorch templates`.
141
+
142
+ Each template should start with a YAML front-matter block:
143
+
144
+ ```yaml
145
+ ---
146
+ name: my_template
147
+ description: Short description shown in `devtorch templates list`
148
+ tags: [reasoning, custom]
149
+ ---
150
+ ```
151
+
152
+ The body can contain `{{variable}}` placeholders. Render with:
153
+
154
+ ```bash
155
+ devtorch templates apply my_template --var key=value --output path/to/file.md
156
+ ```
157
+
158
+ Templates are also discovered from the project-level `templates/` directory.
159
+ """
160
+ readme.write_text(content, encoding="utf-8")
161
+ print(f"Created custom templates directory at {templates_dir}")
162
+ return 0
163
+
164
+
165
+ def _parse_concepts(raw: str | None) -> list[str] | None:
166
+ """Parse comma-separated concepts, returning None if empty."""
167
+ if not raw:
168
+ return None
169
+ parts = [c.strip() for c in raw.split(",") if c.strip()]
170
+ return parts if parts else None
171
+
172
+
173
+ def cmd_commit(args: argparse.Namespace) -> int:
174
+ repo = GCCRepository.at(Path(args.path).resolve())
175
+ concepts = _parse_concepts(getattr(args, "concepts", None))
176
+ try:
177
+ repo.commit(message=args.message or "", concepts=concepts)
178
+ except RuntimeError as e:
179
+ print(f"error: {e}", file=sys.stderr)
180
+ return 1
181
+ if concepts:
182
+ print(f"Recorded commit in {repo.gcc_dir} [concepts: {', '.join(concepts)}]")
183
+ else:
184
+ print(f"Recorded commit in {repo.gcc_dir}")
185
+ return 0
186
+
187
+
188
+ def cmd_chain(args: argparse.Namespace) -> int:
189
+ from devtorch_core.reasoning_plus.learning.chain import build_chain, format_chains
190
+ repo = GCCRepository.at(Path(args.path).resolve())
191
+ if not repo.is_initialized():
192
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
193
+ return 1
194
+ chains = build_chain(
195
+ repo.gcc_dir,
196
+ concept=getattr(args, "concept", None),
197
+ max_chains=getattr(args, "max_chains", 5),
198
+ )
199
+ print(format_chains(chains))
200
+ return 0
201
+
202
+
203
+ def cmd_welcome(args: argparse.Namespace) -> int:
204
+ print("Welcome to DevTorch — governance for AI-assisted codebases.")
205
+ print()
206
+ print("Quick start:")
207
+ print(" 1. devtorch init start tracking this project")
208
+ print(" 2. devtorch status see current state")
209
+ print(" 3. devtorch today daily dashboard")
210
+ print()
211
+ print("Common commands:")
212
+ print(" devtorch setup one-command IDE setup")
213
+ print(" devtorch doctor run health checks")
214
+ print(" devtorch log --tail 20 recent reasoning log")
215
+ print(" devtorch metrics --period 1d recent activity metrics")
216
+ print(" devtorch ask <query> search .GCC/ and project files")
217
+ print()
218
+ print("Need help? Run any command with --help, e.g. devtorch init --help")
219
+ return 0
220
+
221
+
222
+ def cmd_ask(args: argparse.Namespace) -> int:
223
+ """Hybrid grep + semantic search over .GCC/ and project files."""
224
+ from devtorch_core.query import hybrid_search
225
+
226
+ project_root = Path(args.path).resolve()
227
+ if not (project_root / ".GCC").exists():
228
+ print(f"error: {project_root / '.GCC'} not found. Run `devtorch init` first.", file=sys.stderr)
229
+ return 1
230
+
231
+ force_semantic = getattr(args, "semantic", False)
232
+ no_semantic = getattr(args, "no_semantic", False)
233
+ if force_semantic and no_semantic:
234
+ print("error: --semantic and --no-semantic are mutually exclusive.", file=sys.stderr)
235
+ return 1
236
+
237
+ if force_semantic:
238
+ mode = "semantic"
239
+ elif no_semantic:
240
+ mode = "grep"
241
+ else:
242
+ mode = "hybrid"
243
+
244
+ results = hybrid_search(
245
+ query=args.query,
246
+ project_root=project_root,
247
+ top_n=args.top_n,
248
+ mode=mode,
249
+ )
250
+
251
+ if not results:
252
+ print("No results found.")
253
+ return 0
254
+
255
+ print(f"Results for: {args.query}\n")
256
+ for r in results:
257
+ rel_path = r.path
258
+ try:
259
+ rel_path = r.path.relative_to(project_root)
260
+ except ValueError:
261
+ pass
262
+ print(f"{rel_path}:{r.line} [{r.source}] score={r.score:.3f}")
263
+ print(f" {r.snippet}")
264
+ return 0
265
+
266
+
267
+ def cmd_doctor(args: argparse.Namespace) -> int:
268
+ repo = GCCRepository.at(Path(args.path).resolve())
269
+ issues = repo.doctor()
270
+ if getattr(args, "ci", False):
271
+ # In CI the .GCC/ directory is often gitignored and not present; the
272
+ # reusable check is meant to validate code quality, not runtime state.
273
+ issues = [i for i in issues if not (i.kind == "layout" and ".GCC" in i.message and "Missing" in i.message)]
274
+ return _doctor_ci_mode(repo, issues, args)
275
+ if not issues:
276
+ print("devtorch doctor: OK")
277
+ return 0
278
+ print("devtorch doctor: found issues:")
279
+ for issue in issues:
280
+ print(f"- [{issue.kind}] {issue.message}")
281
+ return 1
282
+
283
+
284
+ def cmd_alert(args: argparse.Namespace) -> int:
285
+ """Send a test governance alert to configured channels."""
286
+ from devtorch_core.alerts import (
287
+ AlertEnvelope,
288
+ AlertSeverity,
289
+ dispatch_alert,
290
+ SlackChannel,
291
+ MicrosoftTeamsChannel,
292
+ JiraChannel,
293
+ LinearChannel,
294
+ PagerDutyChannel,
295
+ load_config,
296
+ AlertConfig,
297
+ )
298
+
299
+ severity_map = {
300
+ "info": AlertSeverity.INFO,
301
+ "warning": AlertSeverity.WARNING,
302
+ "critical": AlertSeverity.CRITICAL,
303
+ }
304
+ severity = severity_map[args.severity]
305
+ envelope = AlertEnvelope(
306
+ event_type=args.event_type,
307
+ severity=severity,
308
+ title=args.title,
309
+ body=args.body,
310
+ branch="main",
311
+ commit_id="test",
312
+ metadata={"source": "devtorch-cli"},
313
+ )
314
+
315
+ config = load_config()
316
+ if args.channel:
317
+ # Dispatch only to selected channel
318
+ channel = args.channel
319
+ if not config.is_channel_configured(channel):
320
+ print(f"error: channel '{channel}' is not configured. Set the required environment variables.", file=sys.stderr)
321
+ return 1
322
+ channels = []
323
+ if channel == "slack":
324
+ channels.append(SlackChannel(config.slack_webhook_url))
325
+ elif channel == "teams":
326
+ channels.append(MicrosoftTeamsChannel(config.teams_webhook_url))
327
+ elif channel == "jira":
328
+ channels.append(JiraChannel(config.jira_url, config.jira_project_key, config.jira_api_token, config.jira_email))
329
+ elif channel == "linear":
330
+ channels.append(LinearChannel(config.linear_api_token, config.linear_team_id))
331
+ elif channel == "pagerduty":
332
+ channels.append(PagerDutyChannel(config.pagerduty_routing_key))
333
+ results = [c.deliver(envelope) for c in channels]
334
+ else:
335
+ results = dispatch_alert(envelope, config=config)
336
+
337
+ if not results:
338
+ print("No alert channels configured. Set e.g. DEVTORCH_SLACK_WEBHOOK_URL and try again.")
339
+ return 1
340
+
341
+ exit_code = 0
342
+ for result in results:
343
+ status = "OK" if result.success else "FAIL"
344
+ detail = f" ({result.error})" if result.error else ""
345
+ print(f"{status}: {result.channel}{detail}")
346
+ if not result.success:
347
+ exit_code = 1
348
+ return exit_code
349
+
350
+
351
+ def cmd_cloud_client_config(args: argparse.Namespace) -> int:
352
+ """Install MCP client config for an IDE and update AGENTS.md."""
353
+ from devtorch_core.cloud.client_config import install_client_config
354
+
355
+ try:
356
+ result = install_client_config(
357
+ ide=args.ide,
358
+ org=args.org,
359
+ repo=args.repo,
360
+ api_key=args.api_key,
361
+ domain=args.domain,
362
+ project_root=args.path,
363
+ use_env_key=getattr(args, "use_env_key", False),
364
+ )
365
+ except Exception as e:
366
+ print(f"error: {e}", file=sys.stderr)
367
+ return 1
368
+
369
+ print(f"Installed {result['installed_file']}")
370
+ print(f"DevTorch MCP URL: {result['url']}")
371
+ if "warning" in result:
372
+ print(f"WARNING: {result['warning']}", file=sys.stderr)
373
+ if "warning_use_env_key" in result:
374
+ print(f"WARNING: {result['warning_use_env_key']}", file=sys.stderr)
375
+ if "secret_warning" in result:
376
+ print(f"WARNING: {result['secret_warning']}", file=sys.stderr)
377
+ return 0
378
+
379
+
380
+ def _doctor_ci_mode(repo: Any, issues: List[Any], args: argparse.Namespace) -> int:
381
+ """CI mode: enforce DHS and I3 thresholds in addition to doctor checks."""
382
+ from devtorch_core.metrics.dhs import compute_dhs, DHSComponents, dhs_to_label
383
+ from devtorch_core.metrics.shadow_ai import detect_shadow_ai
384
+
385
+ min_dhs = float(getattr(args, "min_dhs_score", 0.0))
386
+ critical_concepts = [c.strip() for c in getattr(args, "critical_concepts", "").split(",") if c.strip()]
387
+
388
+ exit_code = 0
389
+ messages: List[str] = []
390
+
391
+ if issues:
392
+ exit_code = 1
393
+ messages.append(f"Doctor found {len(issues)} issue(s)")
394
+ for issue in issues:
395
+ messages.append(f" - [{issue.kind}] {issue.message}")
396
+
397
+ # DHS check
398
+ shadow = detect_shadow_ai(repo.root)
399
+ components = DHSComponents(
400
+ commit_health=1.0 if shadow.get("gcc_commits", 0) > 0 else 0.0,
401
+ sensitivity_health=1.0,
402
+ capability_health=0.0,
403
+ variance_health=1.0,
404
+ )
405
+ dhs_score = compute_dhs(components)
406
+ dhs_label = dhs_to_label(dhs_score)
407
+ if min_dhs > 0 and dhs_score < min_dhs:
408
+ exit_code = 1
409
+ messages.append(f"DHS {dhs_score:.2f} below threshold {min_dhs:.2f} ({dhs_label})")
410
+ else:
411
+ messages.append(f"DHS {dhs_score:.2f} ({dhs_label})")
412
+
413
+ # I3 critical-concepts check
414
+ if critical_concepts:
415
+ theta = repo.get_theta().get("coordination_vector", {})
416
+ missing = [c for c in critical_concepts if c not in theta]
417
+ if missing:
418
+ exit_code = 1
419
+ messages.append(f"Critical concepts missing from Θ: {', '.join(missing)}")
420
+ else:
421
+ messages.append(f"Critical concepts present in Θ: {', '.join(critical_concepts)}")
422
+
423
+ prefix = "PASS" if exit_code == 0 else "FAIL"
424
+ print(f"devtorch doctor --ci: {prefix}")
425
+ for msg in messages:
426
+ print(f" {msg}")
427
+ return exit_code
428
+
429
+
430
+ def cmd_branch(args: argparse.Namespace) -> int:
431
+ repo = GCCRepository.at(Path(args.path).resolve())
432
+ try:
433
+ repo.create_branch(args.name, from_ref=args.from_ref)
434
+ except ValueError as e:
435
+ print(f"error: {e}", file=sys.stderr)
436
+ return 1
437
+ print(f"Created branch `{args.name}`.")
438
+ return 0
439
+
440
+
441
+ def cmd_history(args: argparse.Namespace) -> int:
442
+ repo = GCCRepository.at(Path(args.path).resolve())
443
+ history = repo.history(branch=args.branch)
444
+ if not history:
445
+ print("No commits.")
446
+ return 0
447
+ for commit in history:
448
+ cid = commit.get("id")
449
+ msg = commit.get("message", "")
450
+ br = commit.get("branch")
451
+ parent = commit.get("parent")
452
+ ts = commit.get("timestamp")
453
+ print(f"{cid} [{br}] parent={parent} {ts} :: {msg}")
454
+ return 0
455
+
456
+
457
+ def cmd_merge(args: argparse.Namespace) -> int:
458
+ repo = GCCRepository.at(Path(args.path).resolve())
459
+ try:
460
+ repo.merge_fast_forward(source=args.source, target=args.target)
461
+ except RuntimeError as e:
462
+ print(f"error: {e}", file=sys.stderr)
463
+ return 1
464
+ print(f"Merged `{args.source}` into `{args.target or repo._current_branch()}` (fast-forward).")
465
+ return 0
466
+
467
+
468
+ def cmd_context(args: argparse.Namespace) -> int:
469
+ repo = GCCRepository.at(Path(args.path).resolve())
470
+ try:
471
+ bundle = repo.context_bundle(
472
+ k_tokens=args.k,
473
+ policy={"sensitivity_policy": args.policy},
474
+ branch=args.branch,
475
+ explain=args.explain,
476
+ )
477
+ except RuntimeError as e:
478
+ print(f"error: {e}", file=sys.stderr)
479
+ return 1
480
+
481
+ print(f"Context bundle for branch `{bundle['branch']}` "
482
+ f"(approx {bundle['approx_tokens_used']}/{bundle['k_tokens']} tokens):")
483
+ for art in bundle["artifacts"]:
484
+ print(f"- {art['path']} :: {art['reason']} "
485
+ f"(~{art['approx_tokens']} tokens)")
486
+ if args.explain and "bundle_path" in bundle:
487
+ print(f"Bundle persisted as `.GCC/{bundle['bundle_path']}`")
488
+ return 0
489
+
490
+
491
+ def cmd_sensitivity_add(args: argparse.Namespace) -> int:
492
+ repo = GCCRepository.at(Path(args.path).resolve())
493
+ try:
494
+ event = SensitivityEvent(
495
+ source_node=args.source,
496
+ target_concept=args.concept,
497
+ counterfactuals=args.counterfactuals or "",
498
+ confidence=args.confidence,
499
+ disclosure_level=args.disclosure.upper(),
500
+ numerical_value=args.numerical,
501
+ uncertainty=args.uncertainty,
502
+ message=args.message or "",
503
+ )
504
+ event_id = repo.add_sensitivity(event)
505
+ except (ValueError, RuntimeError) as e:
506
+ print(f"error: {e}", file=sys.stderr)
507
+ return 1
508
+ print(f"Sensitivity recorded (id={event_id}).")
509
+ return 0
510
+
511
+
512
+ def cmd_sensitivity_list(args: argparse.Namespace) -> int:
513
+ repo = GCCRepository.at(Path(args.path).resolve())
514
+ try:
515
+ events = repo.list_sensitivities(
516
+ concept=args.concept,
517
+ disclosure_level=args.disclosure.upper() if args.disclosure else None,
518
+ source_node=args.source_node,
519
+ )
520
+ except RuntimeError as e:
521
+ print(f"error: {e}", file=sys.stderr)
522
+ return 1
523
+ if not events:
524
+ print("No sensitivity events found.")
525
+ return 0
526
+ for ev in events:
527
+ num = f" num={ev['numerical_value']}" if ev.get("numerical_value") is not None else ""
528
+ print(
529
+ f"[{ev['disclosure_level']}] {ev['source_node']} → {ev['target_concept']} "
530
+ f"conf={ev['confidence']}{num} id={ev['event_id']}"
531
+ )
532
+ return 0
533
+
534
+
535
+ def cmd_sensitivity_filter(args: argparse.Namespace) -> int:
536
+ """Alias for `sensitivity list` with a mandatory --disclosure filter."""
537
+ repo = GCCRepository.at(Path(args.path).resolve())
538
+ try:
539
+ events = repo.list_sensitivities(
540
+ concept=args.concept,
541
+ disclosure_level=args.disclosure.upper() if args.disclosure else None,
542
+ source_node=args.source_node,
543
+ )
544
+ except RuntimeError as e:
545
+ print(f"error: {e}", file=sys.stderr)
546
+ return 1
547
+ theta = repo.get_theta()
548
+ cv = theta.get("coordination_vector", {})
549
+
550
+ print(f"Filtered sensitivities (disclosure={args.disclosure or 'any'}, "
551
+ f"concept={args.concept or 'any'}):")
552
+ for ev in events:
553
+ num = f" num={ev['numerical_value']}" if ev.get("numerical_value") is not None else ""
554
+ print(
555
+ f" [{ev['disclosure_level']}] {ev['source_node']} → {ev['target_concept']} "
556
+ f"conf={ev['confidence']}{num}"
557
+ )
558
+ print(f"\nΘ coordination vector (all concepts):")
559
+ for concept, entry in cv.items():
560
+ print(
561
+ f" {concept}: mean_conf={entry['mean_confidence']} "
562
+ f"level={entry['disclosure_level']} n={entry['event_count']}"
563
+ )
564
+ return 0
565
+
566
+
567
+ def cmd_capability_set(args: argparse.Namespace) -> int:
568
+ repo = GCCRepository.at(Path(args.path).resolve())
569
+ try:
570
+ cap = OmegaCapability(
571
+ agent_id=args.agent_id,
572
+ lipschitz_bound=args.lipschitz_bound,
573
+ calibration_dataset_hash=args.calibration_hash,
574
+ embedding_model=args.embedding_model,
575
+ calibration_date=args.calibration_date,
576
+ temperature_used=args.temperature,
577
+ lipschitz_p95=args.lipschitz_p95,
578
+ expiry_days=args.expiry_days,
579
+ )
580
+ repo.set_capability(cap)
581
+ except (ValueError, RuntimeError) as e:
582
+ print(f"error: {e}", file=sys.stderr)
583
+ return 1
584
+ print(f"Capability declaration set for agent '{cap.agent_id}'.")
585
+ return 0
586
+
587
+
588
+ def cmd_capability_show(args: argparse.Namespace) -> int:
589
+ repo = GCCRepository.at(Path(args.path).resolve())
590
+ try:
591
+ cap = repo.get_capability()
592
+ except RuntimeError as e:
593
+ print(f"error: {e}", file=sys.stderr)
594
+ return 1
595
+ if cap is None:
596
+ print("No capability declaration set. Run `devtorch capability set` first.")
597
+ return 1
598
+ print(f"Ωᵢ capability for agent '{cap['agent_id']}':")
599
+ for key in (
600
+ "schema_version", "lipschitz_bound", "lipschitz_p95",
601
+ "embedding_model", "calibration_date", "temperature_used",
602
+ "expiry_days", "pst_status", "pst_score",
603
+ "pst_n_prompts", "pst_n_repetitions", "calibration_dataset_hash",
604
+ "created_at", "updated_at",
605
+ ):
606
+ val = cap.get(key)
607
+ if val is not None:
608
+ print(f" {key}: {val}")
609
+ return 0
610
+
611
+
612
+ def cmd_capability_validate(args: argparse.Namespace) -> int:
613
+ repo = GCCRepository.at(Path(args.path).resolve())
614
+ try:
615
+ result = repo.validate_capability(l_max=args.l_max)
616
+ except RuntimeError as e:
617
+ print(f"error: {e}", file=sys.stderr)
618
+ return 1
619
+ if result["textual_mode_allowed"]:
620
+ print(f"Capability valid. Textual Mode: ALLOWED (agent={result['agent_id']}).")
621
+ return 0
622
+ print(f"Capability DEGRADED. Textual Mode: BLOCKED (agent={result['agent_id']}).")
623
+ for reason in result["reasons"]:
624
+ print(f" - {reason}")
625
+ return 1
626
+
627
+
628
+ # Sprint 4: lock, unlock, invariant, concept, variance
629
+
630
+ def cmd_lock(args: argparse.Namespace) -> int:
631
+ repo = GCCRepository.at(Path(args.path).resolve())
632
+ try:
633
+ repo.lock(reason=args.reason or "", required_peer_count=args.peers)
634
+ except RuntimeError as e:
635
+ print(f"error: {e}", file=sys.stderr)
636
+ return 1
637
+ print("Consensus lock acquired.")
638
+ return 0
639
+
640
+
641
+ def cmd_unlock(args: argparse.Namespace) -> int:
642
+ repo = GCCRepository.at(Path(args.path).resolve())
643
+ try:
644
+ repo.unlock()
645
+ except RuntimeError as e:
646
+ print(f"error: {e}", file=sys.stderr)
647
+ return 1
648
+ print("Consensus lock released.")
649
+ return 0
650
+
651
+
652
+ def cmd_invariant_check(args: argparse.Namespace) -> int:
653
+ repo = GCCRepository.at(Path(args.path).resolve())
654
+ try:
655
+ concepts_raw = (args.concepts or "").strip()
656
+ concepts_used = [c.strip() for c in concepts_raw.split(",") if c.strip()] or None
657
+ failures = repo.invariant_check(
658
+ operation=args.operation or "merge",
659
+ branch_tips=None,
660
+ concepts_used=concepts_used,
661
+ )
662
+ except RuntimeError as e:
663
+ print(f"error: {e}", file=sys.stderr)
664
+ return 1
665
+ if not failures:
666
+ print("All invariant checks passed.")
667
+ return 0
668
+ print("Invariant check(s) failed:")
669
+ for f in failures:
670
+ print(f" [{f.invariant_id}] {f.message}")
671
+ print(f" Fix: {f.actionable_fix}")
672
+ return 1
673
+
674
+
675
+ def cmd_concept_add(args: argparse.Namespace) -> int:
676
+ repo = GCCRepository.at(Path(args.path).resolve())
677
+ try:
678
+ repo.concept_add(args.name, args.definition)
679
+ except RuntimeError as e:
680
+ print(f"error: {e}", file=sys.stderr)
681
+ return 1
682
+ print(f"Concept '{args.name}' registered.")
683
+ return 0
684
+
685
+
686
+ def cmd_concept_list(args: argparse.Namespace) -> int:
687
+ repo = GCCRepository.at(Path(args.path).resolve())
688
+ try:
689
+ names = repo.concept_list()
690
+ except RuntimeError as e:
691
+ print(f"error: {e}", file=sys.stderr)
692
+ return 1
693
+ if not names:
694
+ print("No concepts registered.")
695
+ return 0
696
+ for n in names:
697
+ print(n)
698
+ return 0
699
+
700
+
701
+ def cmd_concept_danger(args: argparse.Namespace) -> int:
702
+ from devtorch_core.reasoning_plus.learning.store import LearningStore
703
+ from devtorch_core.reasoning_plus.learning.analytics import danger_zones
704
+ repo = GCCRepository.at(Path(args.path).resolve())
705
+ if not repo.is_initialized():
706
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
707
+ return 1
708
+ store = LearningStore(repo.gcc_dir)
709
+ zones = danger_zones(store, threshold=args.threshold, min_samples=args.min_samples)
710
+ if not zones:
711
+ print(f"No danger zones found (threshold={args.threshold}, min_samples={args.min_samples}).")
712
+ return 0
713
+ print(f"Danger zones (success rate < {args.threshold:.0%}):")
714
+ for z in zones:
715
+ print(
716
+ f" {z['concept']}: success {z['success_rate']:.0%}, "
717
+ f"{z['total_learnings']} learning(s), "
718
+ f"avg confidence {z['avg_confidence']:.2f}"
719
+ )
720
+ return 0
721
+
722
+
723
+ def _try_remote_or_local(
724
+ args: argparse.Namespace,
725
+ tool_name: str,
726
+ tool_args: dict,
727
+ local_fn: Any,
728
+ ) -> int | None:
729
+ """Try remote MCP first, fall back to local on failure.
730
+
731
+ Returns an int return code if remote succeeded, or None if the caller
732
+ should proceed with the local fallback.
733
+ """
734
+ from devtorch_core.cloud.mcp_client import detect_mcp_config, McpClient
735
+
736
+ root = Path(args.path).resolve()
737
+ config = detect_mcp_config(root)
738
+
739
+ if config:
740
+ url, api_key = config
741
+ try:
742
+ client = McpClient(url, api_key, timeout=10.0)
743
+ result = client.call_tool(tool_name, tool_args)
744
+ print(result)
745
+ return 0
746
+ except Exception as e:
747
+ print(f"Remote MCP unavailable ({e}), falling back to local .GCC/...")
748
+
749
+ return None
750
+
751
+
752
+ def cmd_concept_list_all(args: argparse.Namespace) -> int:
753
+ rc = _try_remote_or_local(args, "devtorch_concepts_list", {"learned_only": False}, None)
754
+ if rc is not None:
755
+ return rc
756
+
757
+ from devtorch_core.concept_catalog import ConceptCatalog
758
+ repo = GCCRepository.at(Path(args.path).resolve())
759
+ if not repo.is_initialized():
760
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
761
+ return 1
762
+ catalog = ConceptCatalog(repo.gcc_dir)
763
+ all_concepts = catalog.enumerate_all()
764
+ if not all_concepts:
765
+ print("No concepts found.")
766
+ return 0
767
+ print(f"All concepts ({len(all_concepts)} from multiple sources):\n")
768
+ for cname, sources in all_concepts.items():
769
+ print(f" {cname:<20} {', '.join(sources)}")
770
+ return 0
771
+
772
+
773
+ def cmd_concept_learned(args: argparse.Namespace) -> int:
774
+ rc = _try_remote_or_local(args, "devtorch_concepts_list", {"learned_only": True}, None)
775
+ if rc is not None:
776
+ return rc
777
+
778
+ from devtorch_core.concept_catalog import ConceptCatalog
779
+ repo = GCCRepository.at(Path(args.path).resolve())
780
+ if not repo.is_initialized():
781
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
782
+ return 1
783
+ catalog = ConceptCatalog(repo.gcc_dir)
784
+ learned = catalog.enumerate_learned()
785
+ if not learned:
786
+ print("No concepts with learning data found.")
787
+ return 0
788
+ print("Concepts learned from interactions:\n")
789
+ print(f" {'Concept':<16} {'Learnings':>9} {'Active':>7} {'Success':>8} {'Confidence':>10}")
790
+ print(f" {'─'*16} {'─'*9} {'─'*7} {'─'*8} {'─'*10}")
791
+ for cname, stats in sorted(learned.items()):
792
+ danger = " DANGER" if stats["success_rate"] < 0.4 else ""
793
+ print(
794
+ f" {cname:<16} {stats['learning_count']:>9} {stats['active']:>7} "
795
+ f"{stats['success_rate']:>7.0%} {stats['avg_confidence']:>10.2f}{danger}"
796
+ )
797
+ print(f"\n{len(learned)} concepts with learning data.")
798
+ return 0
799
+
800
+
801
+ def cmd_concept_related(args: argparse.Namespace) -> int:
802
+ rc = _try_remote_or_local(args, "devtorch_concepts_related", {"topic": args.topic}, None)
803
+ if rc is not None:
804
+ return rc
805
+
806
+ from devtorch_core.concept_catalog import ConceptCatalog
807
+ repo = GCCRepository.at(Path(args.path).resolve())
808
+ if not repo.is_initialized():
809
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
810
+ return 1
811
+ catalog = ConceptCatalog(repo.gcc_dir)
812
+ result = catalog.concepts_for_topic(args.topic, hide_empty=True)
813
+ concepts = result.get("concepts", [])
814
+ taxonomy_only = result.get("taxonomy_only", [])
815
+ if not concepts and not taxonomy_only:
816
+ print(f"Topic '{args.topic}' not found or has no concepts.")
817
+ return 0
818
+ print(f"Concepts related to topic '{args.topic}':\n")
819
+ for c in concepts:
820
+ parts = [f" {c['name']:<16}"]
821
+ parts.append(f"sources: {', '.join(c['sources'])}")
822
+ if "learning_count" in c:
823
+ parts.append(f"{c['learning_count']} learnings, {c['success_rate']:.0%} success")
824
+ print(" — ".join(parts))
825
+ if taxonomy_only:
826
+ print(f"\n Also in taxonomy (no data): {', '.join(taxonomy_only)}")
827
+ print(f"\n{len(concepts)} concept(s) with data. Use 'devtorch reasoning history --topic {args.topic}' for full history.")
828
+ return 0
829
+
830
+
831
+ def cmd_topics_list(args: argparse.Namespace) -> int:
832
+ show_all = getattr(args, "all", False)
833
+ rc = _try_remote_or_local(args, "devtorch_topics_list", {"all": show_all}, None)
834
+ if rc is not None:
835
+ return rc
836
+
837
+ from devtorch_core.topics import TopicStore
838
+ repo = GCCRepository.at(Path(args.path).resolve())
839
+ if not repo.is_initialized():
840
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
841
+ return 1
842
+ store = TopicStore(repo.gcc_dir)
843
+ topics = store.list_topics(with_data_only=not show_all)
844
+ if not topics:
845
+ print("No topics found." if show_all else "No topics with captured reasoning. Use --all to see full taxonomy.")
846
+ return 0
847
+ header = "Topics" + (" (full taxonomy):\n" if show_all else " with captured reasoning:\n")
848
+ print(header)
849
+ for t in topics:
850
+ tag = f" [{t['source']}]" if t["source"] != "default" else ""
851
+ print(f" {t['name']:<20} ({len(t['concepts'])} concepts: {', '.join(t['concepts'])}){tag}")
852
+ total = len(topics)
853
+ defaults = sum(1 for t in topics if t["source"] == "default")
854
+ customs = sum(1 for t in topics if t["source"] == "custom")
855
+ derived = sum(1 for t in topics if t["source"] == "derived")
856
+ print(f"\n{total} topics ({defaults} default, {customs} custom, {derived} auto-derived)")
857
+ return 0
858
+
859
+
860
+ def cmd_topics_show(args: argparse.Namespace) -> int:
861
+ from devtorch_core.topics import TopicStore
862
+ from devtorch_core.concept_catalog import ConceptCatalog
863
+ repo = GCCRepository.at(Path(args.path).resolve())
864
+ if not repo.is_initialized():
865
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
866
+ return 1
867
+ store = TopicStore(repo.gcc_dir)
868
+ catalog = ConceptCatalog(repo.gcc_dir)
869
+ concepts = store.get_concepts(args.name)
870
+ if not concepts:
871
+ print(f"Topic '{args.name}' not found.")
872
+ return 1
873
+ topics = store.list_topics(with_data_only=False)
874
+ topic_meta = next((t for t in topics if t["name"] == args.name), None)
875
+ source_tag = f" [{topic_meta['source']}]" if topic_meta and topic_meta["source"] != "default" else ""
876
+ print(f"Topic: {args.name}{source_tag}\n")
877
+
878
+ all_concepts = catalog.enumerate_all()
879
+ learned = catalog.enumerate_learned()
880
+ with_data = []
881
+ without_data = []
882
+ for c in concepts:
883
+ sources = all_concepts.get(c.lower(), [])
884
+ if sources:
885
+ entry = f" {c:<16} [sources: {', '.join(sources)}]"
886
+ if c.lower() in learned:
887
+ stats = learned[c.lower()]
888
+ entry += f" — {stats['learning_count']} learnings, {stats['success_rate']:.0%} success"
889
+ with_data.append(entry)
890
+ else:
891
+ without_data.append(c)
892
+
893
+ if with_data:
894
+ print("Concepts with data:")
895
+ for line in with_data:
896
+ print(line)
897
+ if without_data:
898
+ print(f"\nAlso in taxonomy (no data yet):\n {', '.join(without_data)}")
899
+ if not with_data and not without_data:
900
+ print("No concepts found for this topic.")
901
+ return 0
902
+
903
+
904
+ def cmd_topics_add(args: argparse.Namespace) -> int:
905
+ from devtorch_core.topics import TopicStore
906
+ repo = GCCRepository.at(Path(args.path).resolve())
907
+ if not repo.is_initialized():
908
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
909
+ return 1
910
+ concepts = _parse_concepts(args.concepts) or []
911
+ store = TopicStore(repo.gcc_dir)
912
+ store.add_topic(args.name, concepts)
913
+ print(f"Topic '{args.name}' registered with {len(concepts)} concept(s).")
914
+ return 0
915
+
916
+
917
+ def cmd_topics_remove(args: argparse.Namespace) -> int:
918
+ from devtorch_core.topics import TopicStore
919
+ repo = GCCRepository.at(Path(args.path).resolve())
920
+ if not repo.is_initialized():
921
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
922
+ return 1
923
+ store = TopicStore(repo.gcc_dir)
924
+ removed = store.remove_topic(args.name)
925
+ if removed:
926
+ print(f"Topic '{args.name}' removed.")
927
+ else:
928
+ print(f"Topic '{args.name}' not found.")
929
+ return 1
930
+ return 0
931
+
932
+
933
+ def cmd_topics_derive(args: argparse.Namespace) -> int:
934
+ from devtorch_core.topics import TopicStore
935
+ repo = GCCRepository.at(Path(args.path).resolve())
936
+ if not repo.is_initialized():
937
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
938
+ return 1
939
+ store = TopicStore(repo.gcc_dir)
940
+ derived = store.derive_topics(force=getattr(args, "force", False))
941
+ if not derived:
942
+ print("No topics could be derived from concept co-occurrence (need >= 3 co-occurring concepts).")
943
+ return 0
944
+ print(f"Derived {len(derived)} topic(s) from concept co-occurrence:")
945
+ for label, concepts in derived.items():
946
+ print(f" {label:<20} ({len(concepts)} concepts: {', '.join(concepts)})")
947
+ print("\nRe-derivation skipped on next call unless commits or learnings change.")
948
+ return 0
949
+
950
+
951
+ def cmd_reasoning_history(args: argparse.Namespace) -> int:
952
+ topic = getattr(args, "topic", None)
953
+ concept = getattr(args, "concept", None)
954
+ max_entries = getattr(args, "max_entries", 50)
955
+ agent_id = getattr(args, "agent_id", None)
956
+
957
+ if not topic and not concept:
958
+ print("Provide either --topic or --concept.", file=sys.stderr)
959
+ return 1
960
+
961
+ tool_args = {"max_entries": max_entries}
962
+ if topic:
963
+ tool_args["topic"] = topic
964
+ if concept:
965
+ tool_args["concept"] = concept
966
+ if agent_id:
967
+ tool_args["agent_id"] = agent_id
968
+
969
+ rc = _try_remote_or_local(args, "devtorch_reasoning_history", tool_args, None)
970
+ if rc is not None:
971
+ return rc
972
+
973
+ from devtorch_core.reasoning import ReasoningStore
974
+ from devtorch_core.topics import TopicStore
975
+
976
+ repo = GCCRepository.at(Path(args.path).resolve())
977
+ if not repo.is_initialized():
978
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
979
+ return 1
980
+ store = ReasoningStore(repo)
981
+
982
+ if concept:
983
+ entries = store.query(concept=concept, agent_id=agent_id, k_tokens=args.tokens)
984
+ if not entries:
985
+ print(f"No reasoning entries found for concept '{concept}'.")
986
+ return 0
987
+ print(f"Reasoning for '{concept}' ({len(entries)} entries):\n")
988
+ for e in entries[:max_entries]:
989
+ print(f" [{e.timestamp[:19]}] agent={e.agent_id} conf={e.confidence:.2f} [{e.disclosure_level}]")
990
+ print(f" Reasoning: {e.reasoning_text}")
991
+ if e.decision_text:
992
+ print(f" Decision: {e.decision_text}")
993
+ print()
994
+ return 0
995
+
996
+ if topic:
997
+ topic_store = TopicStore(repo.gcc_dir)
998
+ concepts = topic_store.get_concepts(topic)
999
+ if not concepts:
1000
+ print(f"Topic '{topic}' not found.")
1001
+ return 1
1002
+ print(f"Reasoning history for topic '{topic}' ({len(concepts)} concepts):\n")
1003
+ total = 0
1004
+ for c in concepts:
1005
+ entries = store.query(concept=c, agent_id=agent_id, k_tokens=args.tokens)
1006
+ if entries:
1007
+ shown = entries[:max(0, max_entries - total)]
1008
+ if not shown:
1009
+ break
1010
+ print(f"═══ {c} ({len(entries)} entries, showing {len(shown)}) ═══")
1011
+ for e in shown:
1012
+ print(f" [{e.timestamp[:19]}] agent={e.agent_id} conf={e.confidence:.2f} [{e.disclosure_level}]")
1013
+ print(f" Reasoning: {e.reasoning_text}")
1014
+ if e.decision_text:
1015
+ print(f" Decision: {e.decision_text}")
1016
+ print()
1017
+ total += len(shown)
1018
+ if total >= max_entries:
1019
+ break
1020
+ print(f"Total: {total} reasoning entries (capped at --max-entries {max_entries}) across {len(concepts)} concepts.")
1021
+ return 0
1022
+
1023
+ return 0
1024
+
1025
+
1026
+ def cmd_learning_stats(args: argparse.Namespace) -> int:
1027
+ from devtorch_core.reasoning_plus.learning.store import LearningStore
1028
+ from devtorch_core.reasoning_plus.learning.analytics import concept_stats
1029
+ repo = GCCRepository.at(Path(args.path).resolve())
1030
+ if not repo.is_initialized():
1031
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
1032
+ return 1
1033
+ store = LearningStore(repo.gcc_dir)
1034
+ stats = concept_stats(store)
1035
+ if not stats:
1036
+ print("No learnings found.")
1037
+ return 0
1038
+
1039
+ if args.by_concept:
1040
+ for cname, data in stats.items():
1041
+ print(f"\n [{cname}]")
1042
+ print(f" Total: {data['total']}")
1043
+ print(f" Active: {data['active']} Stale: {data['stale']} Deprecated: {data['deprecated']}")
1044
+ print(f" Avg confidence: {data['avg_confidence']:.2f}")
1045
+ print(f" Success rate: {data['success_rate']:.0%}")
1046
+ if data["by_type"]:
1047
+ types = ", ".join(f"{t}: {c}" for t, c in data["by_type"].most_common())
1048
+ print(f" By type: {types}")
1049
+ else:
1050
+ total_all = sum(d["total"] for d in stats.values())
1051
+ active_all = sum(d["active"] for d in stats.values())
1052
+ avg_conf_all = sum(d["avg_confidence"] * d["total"] for d in stats.values()) / total_all if total_all else 0
1053
+ concepts_with_data = [c for c in stats if c != "__untyped__"]
1054
+ print(f"Total learnings: {total_all}")
1055
+ print(f"Active: {active_all}")
1056
+ print(f"Average confidence: {avg_conf_all:.2f}")
1057
+ print(f"Concepts tracked: {len(concepts_with_data)}")
1058
+ print(f"Danger zones: {sum(1 for d in stats.values() if d['success_rate'] < 0.4)}")
1059
+ return 0
1060
+
1061
+
1062
+ def cmd_variance_calibrate(args: argparse.Namespace) -> int:
1063
+ repo = GCCRepository.at(Path(args.path).resolve())
1064
+ # Parse agent variances: key=value[,key=value...]
1065
+ agent_variances = {}
1066
+ if getattr(args, "variances", None):
1067
+ for part in args.variances.split(","):
1068
+ part = part.strip()
1069
+ if "=" in part:
1070
+ k, v = part.split("=", 1)
1071
+ agent_variances[k.strip()] = float(v.strip())
1072
+ try:
1073
+ report = repo.variance_calibrate(agent_variances)
1074
+ except RuntimeError as e:
1075
+ print(f"error: {e}", file=sys.stderr)
1076
+ return 1
1077
+ print(f"Variance calibration: n={report.n}, σ²_obs={report.sigma_sq_obs:.4f}, f_max={report.f_max}")
1078
+ return 0
1079
+
1080
+
1081
+ def cmd_variance_monitor(args: argparse.Namespace) -> int:
1082
+ repo = GCCRepository.at(Path(args.path).resolve())
1083
+ agent_variances = None
1084
+ if getattr(args, "variances", None):
1085
+ agent_variances = {}
1086
+ for part in args.variances.split(","):
1087
+ part = part.strip()
1088
+ if "=" in part:
1089
+ k, v = part.split("=", 1)
1090
+ agent_variances[k.strip()] = float(v.strip())
1091
+ try:
1092
+ result = repo.variance_monitor_run(agent_variances)
1093
+ except RuntimeError as e:
1094
+ print(f"error: {e}", file=sys.stderr)
1095
+ return 1
1096
+ print(f"Variance monitor: f_max_live={result['f_max_live']}, previous_f_max={result.get('previous_f_max')}, "
1097
+ f"σ²_obs={result.get('sigma_sq_obs', 0):.4f}, alert_emitted={result.get('alert_emitted')}, "
1098
+ f"deterministic_mode_forced={result.get('deterministic_mode_forced')}")
1099
+ return 0
1100
+
1101
+
1102
+ # Sprint 5: REP, SIS-TC, prompt, deltaf, RDP, disclosure
1103
+
1104
+ def cmd_rep_append(args: argparse.Namespace) -> int:
1105
+ repo = GCCRepository.at(Path(args.path).resolve())
1106
+ try:
1107
+ payload = json.loads(args.payload) if args.payload else {}
1108
+ seq = repo.rep_append(args.agent_id, args.round_id, payload, trust=args.trust or 0.5)
1109
+ except RuntimeError as e:
1110
+ print(f"error: {e}", file=sys.stderr)
1111
+ return 1
1112
+ print(f"REP envelope appended (sequence_id={seq}).")
1113
+ return 0
1114
+
1115
+
1116
+ def cmd_rep_checksum(args: argparse.Namespace) -> int:
1117
+ repo = GCCRepository.at(Path(args.path).resolve())
1118
+ cs = repo.rep_replay_checksum()
1119
+ if not cs:
1120
+ print("No REP ledger or repo not initialized.")
1121
+ return 0
1122
+ print(cs)
1123
+ return 0
1124
+
1125
+
1126
+ def cmd_sis_tc_run(args: argparse.Namespace) -> int:
1127
+ repo = GCCRepository.at(Path(args.path).resolve())
1128
+ try:
1129
+ report = repo.sis_tc_run(corpus_path=Path(args.corpus) if getattr(args, "corpus", None) else None)
1130
+ except RuntimeError as e:
1131
+ print(f"error: {e}", file=sys.stderr)
1132
+ return 1
1133
+ print(f"SIS-TC report: n={report.n_total}, FPR={report.fpr:.4f}, FNR={report.fnr:.4f}")
1134
+ return 0
1135
+
1136
+
1137
+ def cmd_prompt_set(args: argparse.Namespace) -> int:
1138
+ repo = GCCRepository.at(Path(args.path).resolve())
1139
+ content = args.content if hasattr(args, "content") and args.content else ""
1140
+ if not content and hasattr(args, "file") and args.file:
1141
+ content = Path(args.file).read_text(encoding="utf-8")
1142
+ try:
1143
+ repo.prompt_set(content, version=getattr(args, "version", "v2.1") or "v2.1")
1144
+ except RuntimeError as e:
1145
+ print(f"error: {e}", file=sys.stderr)
1146
+ return 1
1147
+ print("RACP_SYSTEM_PROMPT_v2.1 stored.")
1148
+ return 0
1149
+
1150
+
1151
+ def cmd_prompt_show(args: argparse.Namespace) -> int:
1152
+ repo = GCCRepository.at(Path(args.path).resolve())
1153
+ content = repo.prompt_get()
1154
+ if content is None:
1155
+ print("No system prompt set.")
1156
+ return 0
1157
+ print(content)
1158
+ return 0
1159
+
1160
+
1161
+ def cmd_deltaf_run(args: argparse.Namespace) -> int:
1162
+ repo = GCCRepository.at(Path(args.path).resolve())
1163
+ try:
1164
+ report = repo.deltaf_run(expiry_days=getattr(args, "expiry_days", None) or 90)
1165
+ except RuntimeError as e:
1166
+ print(f"error: {e}", file=sys.stderr)
1167
+ return 1
1168
+ print(f"Δf report: delta_f={report.delta_f}, expiry={report.expiry_date}")
1169
+ return 0
1170
+
1171
+
1172
+ def cmd_rdp_spend(args: argparse.Namespace) -> int:
1173
+ repo = GCCRepository.at(Path(args.path).resolve())
1174
+ try:
1175
+ allowed, event = repo.rdp_spend(args.round_id, args.epsilon)
1176
+ except RuntimeError as e:
1177
+ print(f"error: {e}", file=sys.stderr)
1178
+ return 1
1179
+ if not allowed:
1180
+ print(f"Privacy budget exhausted: {event}")
1181
+ return 1
1182
+ print("Epsilon spend recorded.")
1183
+ return 0
1184
+
1185
+
1186
+ def cmd_rdp_status(args: argparse.Namespace) -> int:
1187
+ repo = GCCRepository.at(Path(args.path).resolve())
1188
+ ro = repo.rdp_read_only()
1189
+ print("read_only=True" if ro else "read_only=False")
1190
+ return 0
1191
+
1192
+
1193
+ def cmd_disclosure_redact(args: argparse.Namespace) -> int:
1194
+ repo = GCCRepository.at(Path(args.path).resolve())
1195
+ text = args.text if getattr(args, "text", None) else sys.stdin.read()
1196
+ try:
1197
+ out = repo.disclosure_redact(text, padding_length=getattr(args, "padding", None))
1198
+ except RuntimeError as e:
1199
+ print(f"error: {e}", file=sys.stderr)
1200
+ return 1
1201
+ print(out, end="")
1202
+ return 0
1203
+
1204
+
1205
+ # Sprint 19: reasoning subcommand
1206
+
1207
+ def cmd_reasoning_show(args: argparse.Namespace) -> int:
1208
+ rc = _try_remote_or_local(
1209
+ args, "devtorch_reasoning_query",
1210
+ {"concept": args.concept, "agent_id": args.agent_id, "k": args.tokens},
1211
+ None,
1212
+ )
1213
+ if rc is not None:
1214
+ return rc
1215
+
1216
+ from devtorch_core.mcp.server import _tool_devtorch_reasoning_query
1217
+ gcc = GCCRepository.at(Path(args.path).resolve())
1218
+ result = _tool_devtorch_reasoning_query(gcc, {
1219
+ "concept": args.concept,
1220
+ "agent_id": args.agent_id,
1221
+ "k": args.tokens,
1222
+ })
1223
+ print(result)
1224
+ return 0
1225
+
1226
+
1227
+ # Sprint 9: status command
1228
+
1229
+ def cmd_status(args: argparse.Namespace) -> int:
1230
+ repo = GCCRepository.at(Path(args.path).resolve())
1231
+
1232
+ print("─── DevTorch status ───")
1233
+
1234
+ # 1. Current branch
1235
+ branch = repo._current_branch()
1236
+
1237
+ # 2. Last commit
1238
+ history = repo.history(branch=None)
1239
+ if history:
1240
+ last = history[0]
1241
+ commit_id = (last.get("id") or "")[:7]
1242
+ commit_msg = last.get("message", "")
1243
+ commit_str = f'{commit_id} "{commit_msg}"'
1244
+ else:
1245
+ commit_str = "(no commits)"
1246
+
1247
+ print(f'Branch: {branch} | Last commit: {commit_str}')
1248
+
1249
+ # 3. Top 5 Θ hot zones by mean_confidence
1250
+ theta = repo.get_theta()
1251
+ cv = theta.get("coordination_vector", {})
1252
+ sorted_cv = sorted(cv.items(), key=lambda kv: kv[1].get("mean_confidence", 0), reverse=True)
1253
+ hot_zones = " ".join(
1254
+ f"{concept} ({entry['mean_confidence']:.2f})"
1255
+ for concept, entry in sorted_cv[:5]
1256
+ )
1257
+ print(f"Θ hot zones: {hot_zones or '(none)'}")
1258
+
1259
+ # 4. Node state
1260
+ node_state_path = repo.gcc_dir / "NODE_STATE"
1261
+ if node_state_path.exists():
1262
+ node_state = node_state_path.read_text(encoding="utf-8").strip()
1263
+ else:
1264
+ node_state = "UNKNOWN"
1265
+ print(f"Node state: {node_state}")
1266
+
1267
+ # 5. Invariant summary
1268
+ try:
1269
+ failures = repo.invariant_check(operation="status")
1270
+ if not failures:
1271
+ print("Invariants: \u2713 clear")
1272
+ else:
1273
+ print(f"Invariants: {len(failures)} failure(s):")
1274
+ for f in failures:
1275
+ print(f" [{f.invariant_id}] {f.message}")
1276
+ except Exception as e:
1277
+ print(f"Invariants: ERROR ({e})")
1278
+
1279
+ # 6. Consensus lock status
1280
+ locked = repo.is_locked()
1281
+ print(f"Lock: {'LOCKED' if locked else 'UNLOCKED'}")
1282
+
1283
+ print("\nTip: run `devtorch metrics --period 1d` for recent activity.")
1284
+ return 0
1285
+
1286
+
1287
+ # Sprint 9: today command
1288
+
1289
+ def cmd_today(args: argparse.Namespace) -> int:
1290
+ repo = GCCRepository.at(Path(args.path).resolve())
1291
+ if not repo.is_initialized():
1292
+ print("This directory is not tracking DevTorch yet.")
1293
+ print("Run `devtorch init` to start, or `devtorch welcome` for a quick guide.")
1294
+ return 0
1295
+
1296
+ print("═══ DevTorch daily dashboard ═══\n")
1297
+ rc = 0
1298
+
1299
+ status_args = types.SimpleNamespace(path=args.path)
1300
+ try:
1301
+ rc = max(rc, cmd_status(status_args))
1302
+ except Exception as e:
1303
+ print(f"Status: unavailable ({e})")
1304
+ rc = 1
1305
+ print()
1306
+
1307
+ doctor_args = types.SimpleNamespace(path=args.path, ci=False, skip_checks=[])
1308
+ try:
1309
+ rc = max(rc, cmd_doctor(doctor_args))
1310
+ except Exception as e:
1311
+ print(f"Doctor: unavailable ({e})")
1312
+ rc = 1
1313
+ print()
1314
+
1315
+ log_args = types.SimpleNamespace(
1316
+ path=args.path, tail=getattr(args, "tail", 20), branch=None
1317
+ )
1318
+ try:
1319
+ rc = max(rc, cmd_log(log_args))
1320
+ except Exception as e:
1321
+ print(f"Log: unavailable ({e})")
1322
+ rc = 1
1323
+ print()
1324
+
1325
+ metrics_args = types.SimpleNamespace(
1326
+ path=args.path,
1327
+ period="1d",
1328
+ as_json=False,
1329
+ tokens=False,
1330
+ speedup=False,
1331
+ all_projects=False,
1332
+ delivery_time=False,
1333
+ )
1334
+ try:
1335
+ rc = max(rc, cmd_metrics(metrics_args))
1336
+ except Exception as e:
1337
+ print(f"Metrics: unavailable ({e})")
1338
+ rc = 1
1339
+
1340
+ try:
1341
+ from devtorch_core.reasoning_plus.learning.store import LearningStore
1342
+ from devtorch_core.reasoning_plus.learning.analytics import concept_health_section
1343
+ health = concept_health_section(LearningStore(repo.gcc_dir), threshold=0.4)
1344
+ if health:
1345
+ print()
1346
+ print(health)
1347
+ except Exception as e:
1348
+ print(f"Concept health: unavailable ({e})")
1349
+ rc = 1
1350
+
1351
+ try:
1352
+ from devtorch_core.reasoning_plus.learning.chain import build_chain, format_chain
1353
+ recent = build_chain(repo.gcc_dir, concept=None, max_chains=2)
1354
+ if recent:
1355
+ print()
1356
+ print("─── Recent reasoning chains ───")
1357
+ for i, chain in enumerate(recent, 1):
1358
+ print()
1359
+ print(format_chain(chain, indent=" "))
1360
+ except Exception as e:
1361
+ print(f"Reasoning chains: unavailable ({e})")
1362
+ rc = 1
1363
+
1364
+ try:
1365
+ from devtorch_core.cloud.sync_conflicts import ConflictLog
1366
+ summary = ConflictLog(repo.gcc_dir).summary()
1367
+ if summary:
1368
+ print()
1369
+ print(summary)
1370
+ except Exception as e:
1371
+ print(f"Sync conflicts: unavailable ({e})")
1372
+ rc = 1
1373
+
1374
+ print("\n═══ End of dashboard ═══")
1375
+ return rc
1376
+
1377
+
1378
+ # Sprint 9: log command
1379
+
1380
+ def cmd_log(args: argparse.Namespace) -> int:
1381
+ repo = GCCRepository.at(Path(args.path).resolve())
1382
+ log_path = repo.gcc_dir / "log.md"
1383
+
1384
+ if not log_path.exists():
1385
+ print("No log found. Run `devtorch init` first.")
1386
+ return 0
1387
+
1388
+ if getattr(args, "branch", None):
1389
+ print("(branch filter not yet supported — log.md is global)")
1390
+
1391
+ content = log_path.read_text(encoding="utf-8")
1392
+ lines = content.splitlines()
1393
+
1394
+ tail = getattr(args, "tail", None)
1395
+ if tail is not None:
1396
+ lines = lines[-tail:]
1397
+
1398
+ print("\n".join(lines))
1399
+ return 0
1400
+
1401
+
1402
+ # S7: proxy subcommand (server in devtorch_core/proxy/)
1403
+
1404
+ def cmd_proxy_start(args: argparse.Namespace) -> int:
1405
+ port = getattr(args, "port", 8765)
1406
+ host = getattr(args, "host", "127.0.0.1")
1407
+ try:
1408
+ from devtorch_core.proxy.server import run_proxy
1409
+ run_proxy(port=port, host=host)
1410
+ except ImportError:
1411
+ print("Install proxy deps: pip install devtorch[proxy]")
1412
+ return 1
1413
+ return 0
1414
+
1415
+
1416
+ def cmd_proxy_stop(args: argparse.Namespace) -> int:
1417
+ print("The proxy runs in the foreground. To stop it:")
1418
+ print(" - Press Ctrl+C in the terminal where it is running, or")
1419
+ print(" - Find the PID with `lsof -i :8765` and run `kill <PID>`.")
1420
+ return 0
1421
+
1422
+
1423
+ def cmd_proxy_install(args: argparse.Namespace) -> int:
1424
+ try:
1425
+ from devtorch_core.proxy.server import install_service
1426
+ install_service()
1427
+ except ImportError:
1428
+ print("Install proxy deps: pip install devtorch[proxy]")
1429
+ return 1
1430
+ return 0
1431
+
1432
+
1433
+ def cmd_proxy_status(args: argparse.Namespace) -> int:
1434
+ import socket
1435
+ port = 8765
1436
+ try:
1437
+ with socket.create_connection(("127.0.0.1", port), timeout=1):
1438
+ print(f"Proxy running on :{port}")
1439
+ except (ConnectionRefusedError, OSError):
1440
+ print("Proxy not running")
1441
+ return 0
1442
+
1443
+
1444
+ # S26: daemon subcommand
1445
+
1446
+
1447
+ def cmd_daemon_start(args: argparse.Namespace) -> int:
1448
+ from devtorch_core.daemon import start_daemon
1449
+
1450
+ project_root = Path(args.path).resolve()
1451
+ ok, message = start_daemon(
1452
+ project_root,
1453
+ run_server=getattr(args, "run_server", False),
1454
+ host=getattr(args, "host", "127.0.0.1"),
1455
+ port=int(getattr(args, "port", 8765)),
1456
+ )
1457
+ print(message)
1458
+ return 0 if ok else 1
1459
+
1460
+
1461
+ def cmd_daemon_stop(args: argparse.Namespace) -> int:
1462
+ from devtorch_core.daemon import stop_daemon
1463
+
1464
+ project_root = Path(args.path).resolve()
1465
+ ok, message = stop_daemon(project_root)
1466
+ print(message)
1467
+ return 0 if ok else 1
1468
+
1469
+
1470
+ def cmd_daemon_status(args: argparse.Namespace) -> int:
1471
+ from devtorch_core.daemon import daemon_status
1472
+
1473
+ project_root = Path(args.path).resolve()
1474
+ status = daemon_status(project_root)
1475
+ if status.get("error"):
1476
+ print(f"error: {status['error']}", file=sys.stderr)
1477
+ return 1
1478
+ if status["running"]:
1479
+ print(f"DevTorch daemon running (PID {status['pid']})")
1480
+ else:
1481
+ print("DevTorch daemon stopped")
1482
+ print(f"Log: {status['log_path']}")
1483
+ return 0
1484
+
1485
+
1486
+ def cmd_daemon_logs(args: argparse.Namespace) -> int:
1487
+ from devtorch_core.daemon import daemon_logs
1488
+
1489
+ project_root = Path(args.path).resolve()
1490
+ lines = daemon_logs(project_root, lines=getattr(args, "lines", 50))
1491
+ if not lines:
1492
+ print("(no daemon log entries)")
1493
+ return 0
1494
+ for line in lines:
1495
+ print(line)
1496
+ return 0
1497
+
1498
+
1499
+ # Sprint 9: event log signing
1500
+
1501
+ def cmd_signing_enable(args: argparse.Namespace) -> int:
1502
+ from devtorch_core.signing import enable_signing
1503
+ gcc_dir = Path(args.path).resolve() / ".GCC"
1504
+ if not gcc_dir.exists():
1505
+ print(f"error: {gcc_dir} not found. Run `devtorch init` first.", file=sys.stderr)
1506
+ return 1
1507
+ enable_signing(gcc_dir)
1508
+ print(f"Event log signing enabled for {gcc_dir}.")
1509
+ print(" Key: .GCC/signing.key")
1510
+ print(" Config: .GCC/signing.json")
1511
+ print(" New events will carry HMAC-SHA256 signatures.")
1512
+ return 0
1513
+
1514
+
1515
+ def cmd_signing_disable(args: argparse.Namespace) -> int:
1516
+ from devtorch_core.signing import disable_signing
1517
+ gcc_dir = Path(args.path).resolve() / ".GCC"
1518
+ if not gcc_dir.exists():
1519
+ print(f"error: {gcc_dir} not found. Run `devtorch init` first.", file=sys.stderr)
1520
+ return 1
1521
+ disable_signing(gcc_dir)
1522
+ print(f"Event log signing disabled for {gcc_dir}. Existing signatures remain intact.")
1523
+ return 0
1524
+
1525
+
1526
+ def cmd_signing_verify(args: argparse.Namespace) -> int:
1527
+ from devtorch_core.signing import verify_log
1528
+ gcc_dir = Path(args.path).resolve() / ".GCC"
1529
+ if not gcc_dir.exists():
1530
+ print(f"error: {gcc_dir} not found. Run `devtorch init` first.", file=sys.stderr)
1531
+ return 1
1532
+ failures = verify_log(gcc_dir)
1533
+ if not failures:
1534
+ print("Event log signature verification: OK")
1535
+ return 0
1536
+ print(f"Event log signature verification: {len(failures)} failure(s)")
1537
+ for f in failures:
1538
+ print(f" line {f['line_no']}: {f['reason']}")
1539
+ return 1
1540
+
1541
+
1542
+ # Sprint 19: project registry
1543
+
1544
+ def cmd_projects_add(args: argparse.Namespace) -> int:
1545
+ from devtorch_core.projects import ProjectRegistry
1546
+ try:
1547
+ registry = ProjectRegistry()
1548
+ entry = registry.add(Path(args.path), name=args.name, developer_id=args.developer_id)
1549
+ except ValueError as e:
1550
+ print(f"error: {e}", file=sys.stderr)
1551
+ return 1
1552
+ print(f"Added project `{entry.name}` at {entry.path}")
1553
+ print(f" developer_id: {entry.developer_id}")
1554
+
1555
+ if getattr(args, "configure_mcp", False):
1556
+ from devtorch_core.hooks.installer import install_all_hooks, install_cursor_mcp
1557
+ hooks_result = install_all_hooks(str(entry.path))
1558
+ if hooks_result.claude_hooks_written:
1559
+ print(" MCP: .claude/hooks.json written")
1560
+ cursor_ok, cursor_msg = install_cursor_mcp(str(entry.path))
1561
+ if cursor_ok:
1562
+ print(" MCP: .cursor/mcp.json written")
1563
+ else:
1564
+ print(f" MCP: .cursor/mcp.json skipped ({cursor_msg})")
1565
+ return 0
1566
+
1567
+
1568
+ def cmd_projects_list(args: argparse.Namespace) -> int:
1569
+ from devtorch_core.projects import ProjectRegistry
1570
+ registry = ProjectRegistry()
1571
+ entries = registry.list()
1572
+ if not entries:
1573
+ print("No projects registered. Run `devtorch projects add <path>`.")
1574
+ return 0
1575
+ print(f"{'Name':<20} {'Path':<40} {'Developer':<25} {'Last Synced'}")
1576
+ print("-" * 100)
1577
+ for e in entries:
1578
+ print(f"{e.name:<20} {e.path:<40} {e.developer_id:<25} {e.last_synced[:19]}")
1579
+ return 0
1580
+
1581
+
1582
+ def cmd_projects_remove(args: argparse.Namespace) -> int:
1583
+ from devtorch_core.projects import ProjectRegistry
1584
+ registry = ProjectRegistry()
1585
+ removed = registry.remove(Path(args.path))
1586
+ if removed:
1587
+ print(f"Removed project at {args.path}")
1588
+ return 0
1589
+ print(f"No project registered at {args.path}", file=sys.stderr)
1590
+ return 1
1591
+
1592
+
1593
+ def cmd_projects_sync(args: argparse.Namespace) -> int:
1594
+ from devtorch_core.projects import ProjectRegistry
1595
+ registry = ProjectRegistry()
1596
+ entries = registry.sync()
1597
+ print(f"Synced {len(entries)} project(s).")
1598
+ return 0
1599
+
1600
+
1601
+ # Sprint 6: debug views
1602
+
1603
+ def cmd_debug_timeline(args: argparse.Namespace) -> int:
1604
+ repo = GCCRepository.at(Path(args.path).resolve())
1605
+ filter_types = None
1606
+ if getattr(args, "filter", None):
1607
+ filter_types = [t.strip() for t in args.filter.split(",") if t.strip()]
1608
+ events = repo.debug_timeline(filter_types=filter_types)
1609
+ for e in events:
1610
+ ts = e.get("timestamp", "")
1611
+ et = e.get("event_type", "")
1612
+ payload = e.get("payload", {})
1613
+ print(f"{ts} {et} {payload}")
1614
+ return 0
1615
+
1616
+
1617
+ def cmd_debug_branch_compare(args: argparse.Namespace) -> int:
1618
+ repo = GCCRepository.at(Path(args.path).resolve())
1619
+ b1 = args.branch1
1620
+ b2 = args.branch2
1621
+ try:
1622
+ data = repo.debug_branch_compare(b1, b2)
1623
+ except RuntimeError as e:
1624
+ print(f"error: {e}", file=sys.stderr)
1625
+ return 1
1626
+ print(f"Branch: {b1}")
1627
+ for c in data["branch1"]:
1628
+ print(f" {c.get('id')} {c.get('timestamp')} {c.get('message')}")
1629
+ print(f"\nBranch: {b2}")
1630
+ for c in data["branch2"]:
1631
+ print(f" {c.get('id')} {c.get('timestamp')} {c.get('message')}")
1632
+ print("\nΘ (coordination vector):")
1633
+ for k, v in (data.get("theta") or {}).items():
1634
+ print(f" {k}: {v}")
1635
+ return 0
1636
+
1637
+
1638
+ def cmd_debug_invariant_trace(args: argparse.Namespace) -> int:
1639
+ repo = GCCRepository.at(Path(args.path).resolve())
1640
+ try:
1641
+ traces = repo.debug_invariant_trace()
1642
+ except RuntimeError as e:
1643
+ print(f"error: {e}", file=sys.stderr)
1644
+ return 1
1645
+ if not traces:
1646
+ print("No invariant failures (trace clear).")
1647
+ return 0
1648
+ print("Invariant trace (failures):")
1649
+ for t in traces:
1650
+ print(f" [{t['invariant_id']}] {t['message']}")
1651
+ print(f" Fix: {t['actionable_fix']}")
1652
+ return 1
1653
+
1654
+
1655
+ _CLAUDE_MD_BLOCK = """
1656
+ ## DevTorch — Reasoning Capture
1657
+
1658
+ This project uses DevTorch for AI governance. Follow these rules on every task:
1659
+
1660
+ 1. **Before writing code**: call `devtorch_commit` (MCP tool) with your reasoning.
1661
+ Include what you decided and why, not just what you did.
1662
+
1663
+ 2. **When touching sensitive areas** (auth, payments, database schema, secrets,
1664
+ PII, external APIs): call `devtorch_sensitivity_add` with the concept name,
1665
+ your confidence (0–1), and disclosure level (PUBLIC / PROTECTED / PRIVATE).
1666
+
1667
+ 3. **At the start of a new task**: call `devtorch_context` to retrieve prior
1668
+ decisions before making new ones.
1669
+ """
1670
+
1671
+ def cmd_setup(args: argparse.Namespace) -> int:
1672
+ """
1673
+ One-command setup: init + hooks + MCP server config + optional CLAUDE.md.
1674
+ Idempotent — safe to run on an already-configured project.
1675
+ """
1676
+ import shutil
1677
+
1678
+ project_root = Path(args.path).resolve()
1679
+ ok_mark = "✓"
1680
+ skip_mark = "–"
1681
+ fail_mark = "✗"
1682
+
1683
+ print("DevTorch setup\n")
1684
+
1685
+ # ------------------------------------------------------------------ #
1686
+ # Detect IDE(s) and project type
1687
+ # ------------------------------------------------------------------ #
1688
+ from devtorch_core.hooks.installer import (
1689
+ _detect_ides,
1690
+ _detect_project_type,
1691
+ )
1692
+
1693
+ detected_ides = _detect_ides(str(project_root))
1694
+ project_type = _detect_project_type(str(project_root))
1695
+ print(f"Detected project type: {project_type}")
1696
+ print(f"Detected IDE(s): {', '.join(detected_ides) if detected_ides else 'none'}")
1697
+ print()
1698
+
1699
+ # ------------------------------------------------------------------ #
1700
+ # Step 1: devtorch init
1701
+ # ------------------------------------------------------------------ #
1702
+ repo = GCCRepository.at(project_root)
1703
+ if repo.is_initialized():
1704
+ print(f" {skip_mark} .GCC/ already initialized — skipping")
1705
+ else:
1706
+ try:
1707
+ repo.init()
1708
+ print(f" {ok_mark} .GCC/ initialized at {repo.gcc_dir}")
1709
+ except Exception as exc:
1710
+ print(f" {fail_mark} .GCC/ init failed: {exc}", file=sys.stderr)
1711
+ return 1
1712
+
1713
+ # ------------------------------------------------------------------ #
1714
+ # Step 2: Claude Code hooks (hooks key in .claude/settings.json)
1715
+ # ------------------------------------------------------------------ #
1716
+ from devtorch_core.hooks.installer import install_all_hooks, get_hooks_status
1717
+
1718
+ hooks_status = get_hooks_status(str(project_root))
1719
+ if hooks_status["claude_hooks"]:
1720
+ print(f" {skip_mark} .claude/settings.json hooks already installed — skipping")
1721
+ else:
1722
+ result = install_all_hooks(str(project_root))
1723
+ if result.claude_hooks_written:
1724
+ print(f" {ok_mark} .claude/settings.json hooks installed")
1725
+ else:
1726
+ err = result.errors[0] if result.errors else "unknown error"
1727
+ print(f" {fail_mark} .claude/settings.json hooks failed: {err}")
1728
+
1729
+ if hooks_status["git_hook"]:
1730
+ print(f" {skip_mark} .git/hooks/commit-msg already installed — skipping")
1731
+ elif not hooks_status["claude_hooks"]:
1732
+ # result was assigned in the else-branch above
1733
+ if result.git_hook_written:
1734
+ print(f" {ok_mark} .git/hooks/commit-msg written")
1735
+ else:
1736
+ print(f" {skip_mark} .git/hooks/commit-msg no .git repo found (skipped)")
1737
+ else:
1738
+ print(f" {skip_mark} .git/hooks/commit-msg no .git repo found (skipped)")
1739
+
1740
+ # ------------------------------------------------------------------ #
1741
+ # Step 3: Claude Code MCP server (<project-root>/.mcp.json)
1742
+ # ------------------------------------------------------------------ #
1743
+ # Claude Code only reads project-scope MCP servers from .mcp.json at the
1744
+ # project root; a mcpServers key in .claude/settings.json is ignored.
1745
+ from devtorch_core.hooks.installer import install_claude_mcp
1746
+
1747
+ mcp_path = project_root / ".mcp.json"
1748
+ try:
1749
+ pre_existing = json.loads(mcp_path.read_text(encoding="utf-8")).get("mcpServers", {}) \
1750
+ if mcp_path.exists() else {}
1751
+ except Exception:
1752
+ pre_existing = {}
1753
+
1754
+ ok, path_or_err = install_claude_mcp(str(project_root))
1755
+ if not ok:
1756
+ print(f" {fail_mark} .mcp.json failed: {path_or_err}", file=sys.stderr)
1757
+ elif "devtorch" in pre_existing:
1758
+ print(f" {skip_mark} .mcp.json MCP entry already present — skipping")
1759
+ else:
1760
+ print(f" {ok_mark} .mcp.json MCP server entry added")
1761
+
1762
+ # ------------------------------------------------------------------ #
1763
+ # Step 4: CLAUDE.md DevTorch instructions
1764
+ # ------------------------------------------------------------------ #
1765
+ if not getattr(args, "skip_claude_md", False):
1766
+ claude_md_path = project_root / "CLAUDE.md"
1767
+ marker = "## DevTorch"
1768
+ if claude_md_path.exists() and marker in claude_md_path.read_text(encoding="utf-8"):
1769
+ print(f" {skip_mark} CLAUDE.md DevTorch section already present — skipping")
1770
+ else:
1771
+ try:
1772
+ with claude_md_path.open("a", encoding="utf-8") as f:
1773
+ f.write(_CLAUDE_MD_BLOCK)
1774
+ action = "appended to" if claude_md_path.stat().st_size > len(_CLAUDE_MD_BLOCK) else "created"
1775
+ print(f" {ok_mark} CLAUDE.md DevTorch instructions {action}")
1776
+ except Exception as exc:
1777
+ print(f" {fail_mark} CLAUDE.md failed: {exc}", file=sys.stderr)
1778
+
1779
+ # ------------------------------------------------------------------ #
1780
+ # Step 5: AGENTS.md DevTorch instructions (Google Antigravity)
1781
+ # ------------------------------------------------------------------ #
1782
+ if not getattr(args, "skip_agents_md", False):
1783
+ from devtorch_core.hooks.installer import install_agents_md
1784
+ agents_md_path = project_root / "AGENTS.md"
1785
+ marker = "## DevTorch"
1786
+ if agents_md_path.exists() and marker in agents_md_path.read_text(encoding="utf-8"):
1787
+ print(f" {skip_mark} AGENTS.md DevTorch section already present — skipping")
1788
+ else:
1789
+ ok, path_or_err = install_agents_md(str(project_root))
1790
+ if ok:
1791
+ action = "appended to" if agents_md_path.exists() and agents_md_path.stat().st_size > len(
1792
+ "\n## DevTorch"
1793
+ ) else "created"
1794
+ print(f" {ok_mark} AGENTS.md DevTorch instructions {action}")
1795
+ else:
1796
+ print(f" {fail_mark} AGENTS.md failed: {path_or_err}", file=sys.stderr)
1797
+
1798
+ # ------------------------------------------------------------------ #
1799
+ # Step 6/7: IDE-specific MCP config
1800
+ # ------------------------------------------------------------------ #
1801
+ target = getattr(args, "target", None)
1802
+ if target is None or target == "auto":
1803
+ targets = detected_ides if detected_ides else ["claude-code"]
1804
+ else:
1805
+ targets = [target]
1806
+
1807
+ from devtorch_core.hooks.installer import (
1808
+ install_cursor_mcp,
1809
+ install_antigravity_mcp,
1810
+ )
1811
+
1812
+ for target in targets:
1813
+ if target == "cursor":
1814
+ cursor_mcp = project_root / ".cursor" / "mcp.json"
1815
+ already = (
1816
+ cursor_mcp.exists()
1817
+ and "devtorch" in json.loads(cursor_mcp.read_text()).get("mcpServers", {})
1818
+ )
1819
+ if already:
1820
+ print(f" {skip_mark} .cursor/mcp.json MCP entry already present — skipping")
1821
+ else:
1822
+ ok, path_or_err = install_cursor_mcp(str(project_root))
1823
+ if ok:
1824
+ print(f" {ok_mark} .cursor/mcp.json MCP server entry added")
1825
+ else:
1826
+ print(f" {fail_mark} .cursor/mcp.json failed: {path_or_err}", file=sys.stderr)
1827
+ print()
1828
+ print(" Next: install the VS Code extension in Cursor:")
1829
+ print(" Extensions: Install from VSIX... →")
1830
+ print(" extensions/vscode/devtorch-0.1.0.vsix")
1831
+
1832
+ elif target == "antigravity":
1833
+ ag_mcp = project_root / "mcp_config.json"
1834
+ already = (
1835
+ ag_mcp.exists()
1836
+ and "devtorch" in json.loads(ag_mcp.read_text()).get("mcpServers", {})
1837
+ )
1838
+ if already:
1839
+ print(f" {skip_mark} mcp_config.json MCP entry already present — skipping")
1840
+ else:
1841
+ ok, path_or_err = install_antigravity_mcp(str(project_root))
1842
+ if ok:
1843
+ print(f" {ok_mark} mcp_config.json MCP server entry added")
1844
+ else:
1845
+ print(f" {fail_mark} mcp_config.json failed: {path_or_err}", file=sys.stderr)
1846
+
1847
+ # ------------------------------------------------------------------ #
1848
+ # Step 5.5: OpenCode hooks (PreUserPrompt for context bundle)
1849
+ # ------------------------------------------------------------------ #
1850
+ if "opencode" in detected_ides:
1851
+ from devtorch_core.hooks.installer import install_opencode_hooks
1852
+
1853
+ ok, path_or_err = install_opencode_hooks(str(project_root))
1854
+ if ok:
1855
+ print(f" {ok_mark} opencode.json hooks installed")
1856
+ else:
1857
+ print(f" {fail_mark} opencode.json hooks failed: {path_or_err}", file=sys.stderr)
1858
+
1859
+ # ------------------------------------------------------------------ #
1860
+ # Done
1861
+ # ------------------------------------------------------------------ #
1862
+ print()
1863
+ print("Setup complete. Start Claude Code and run: devtorch status")
1864
+ if not shutil.which("devtorch"):
1865
+ print()
1866
+ print(" NOTE: 'devtorch' not found in PATH.")
1867
+ print(" Activate your venv first: source .venv/bin/activate")
1868
+ return 0
1869
+
1870
+
1871
+ def cmd_calibrate(args: argparse.Namespace) -> int:
1872
+ from devtorch_core.metrics.calibrate import CalibrationStore, run_calibrate_wizard
1873
+
1874
+ gcc_dir = Path(args.path).resolve() / ".GCC"
1875
+
1876
+ if getattr(args, "set_value", None):
1877
+ # Parse KEY=VALUE
1878
+ raw = args.set_value.strip()
1879
+ if "=" not in raw:
1880
+ print(f"error: --set expects KEY=VALUE, got: {raw!r}", file=sys.stderr)
1881
+ return 1
1882
+ key, _, value_str = raw.partition("=")
1883
+ key = key.strip()
1884
+ value_str = value_str.strip()
1885
+ # Try numeric coercion
1886
+ try:
1887
+ value: object = float(value_str)
1888
+ except ValueError:
1889
+ value = value_str
1890
+ try:
1891
+ store = CalibrationStore(gcc_dir)
1892
+ store.set_value(key, value)
1893
+ print(f"Calibration set: {key} = {value}")
1894
+ except Exception as exc:
1895
+ print(f"error: {exc}", file=sys.stderr)
1896
+ return 1
1897
+ return 0
1898
+
1899
+ # Interactive wizard
1900
+ try:
1901
+ result = run_calibrate_wizard(gcc_dir)
1902
+ print("Calibration saved:")
1903
+ for k, v in sorted(result.items()):
1904
+ print(f" {k}: {v}")
1905
+ except Exception as exc:
1906
+ print(f"error: {exc}", file=sys.stderr)
1907
+ return 1
1908
+ return 0
1909
+
1910
+
1911
+ def cmd_metrics(args: argparse.Namespace) -> int:
1912
+ import glob as _glob
1913
+
1914
+ from devtorch_core.metrics.calibrate import CalibrationStore
1915
+ from devtorch_core.metrics.delivery_time import compute_delivery_time_summary
1916
+ from devtorch_core.metrics.shadow_ai import detect_shadow_ai
1917
+ from devtorch_core.metrics.dhs import compute_dhs, DHSComponents, dhs_to_label
1918
+
1919
+ repo_root = Path(args.path).resolve()
1920
+ gcc_dir = repo_root / ".GCC"
1921
+
1922
+ # Parse period
1923
+ period_str = getattr(args, "period", "30d")
1924
+ try:
1925
+ period_days = int(period_str.rstrip("d"))
1926
+ except (ValueError, AttributeError):
1927
+ period_days = 30
1928
+
1929
+ # Load calibration
1930
+ try:
1931
+ store = CalibrationStore(gcc_dir)
1932
+ calibration = store.load()
1933
+ except Exception:
1934
+ calibration = {}
1935
+
1936
+ # Sprint 20: token/speedup breakdowns
1937
+ from devtorch_core.metrics.aggregate import (
1938
+ aggregate_tokens,
1939
+ aggregate_speedup,
1940
+ aggregate_by_developer,
1941
+ )
1942
+
1943
+ if getattr(args, "tokens", False):
1944
+ if getattr(args, "all_projects", False):
1945
+ from devtorch_core.metrics.aggregate import aggregate_all_projects
1946
+ all_projects = aggregate_all_projects()
1947
+ if getattr(args, "as_json", False):
1948
+ print(json.dumps(all_projects, indent=2))
1949
+ return 0
1950
+ print("DevTorch Token Metrics — All Projects\n")
1951
+ print(f" {'Project':<20} {'Sessions':>10} {'Used':>12} {'Saved':>12}")
1952
+ print(" " + "-" * 60)
1953
+ for p in all_projects["projects"]:
1954
+ print(f" {p['name']:<20} {p['sessions_count']:>10} {p['tokens_used']:>12,} {p['tokens_saved']:>12,}")
1955
+ agg = all_projects["aggregate"]
1956
+ print(" " + "-" * 60)
1957
+ print(f" {'AGGREGATE':<20} {agg['sessions_count']:>10} {agg['tokens_used']:>12,} {agg['tokens_saved']:>12,}")
1958
+ return 0
1959
+
1960
+ agg = aggregate_tokens(gcc_dir)
1961
+ by_dev = aggregate_by_developer(gcc_dir)
1962
+ token_cost = calibration.get("token_cost_usd_per_million")
1963
+ if token_cost:
1964
+ cost_saved_usd = (agg['total_tokens_saved'] / 1_000_000) * float(token_cost)
1965
+ else:
1966
+ cost_saved_usd = None
1967
+ if getattr(args, "as_json", False):
1968
+ output = {
1969
+ "project": agg,
1970
+ "per_developer": by_dev,
1971
+ "cost_saved_usd": cost_saved_usd,
1972
+ "cost_calibrated": bool(token_cost),
1973
+ }
1974
+ print(json.dumps(output, indent=2))
1975
+ return 0
1976
+ print("DevTorch Token Metrics\n")
1977
+ print(f" Sessions: {agg['sessions_count']}")
1978
+ print(f" Tokens used: {agg['total_tokens_used']:,} [MEASURED]")
1979
+ print(f" Tokens saved: {agg['total_tokens_saved']:,} [CALIBRATED]")
1980
+ print(f" Bundle tokens: {agg['total_bundle_tokens']:,} [MEASURED]")
1981
+ print(f" Full history tokens: {agg['total_full_history_tokens']:,} [MEASURED]")
1982
+ if cost_saved_usd is not None:
1983
+ print(f" Cost saved (USD): ${cost_saved_usd:,.2f} [CALIBRATED]")
1984
+ else:
1985
+ print(" Cost saved (USD): Set token_cost_usd_per_million to unlock [UNAVAILABLE]")
1986
+ if by_dev:
1987
+ print("\n Per developer:")
1988
+ for dev_id, dev in sorted(by_dev.items()):
1989
+ print(f" {dev_id}: used={dev['tokens_used']:,} saved={dev['tokens_saved']:,} sessions={dev['sessions_count']}")
1990
+ return 0
1991
+
1992
+ if getattr(args, "speedup", False):
1993
+ if getattr(args, "all_projects", False):
1994
+ from devtorch_core.metrics.aggregate import aggregate_all_projects
1995
+ all_projects = aggregate_all_projects()
1996
+ if getattr(args, "as_json", False):
1997
+ print(json.dumps(all_projects, indent=2))
1998
+ return 0
1999
+ print("DevTorch Speedup Metrics — All Projects\n")
2000
+ print(f" {'Project':<20} {'Sessions':>10} {'Speedup':>10}")
2001
+ print(" " + "-" * 45)
2002
+ for p in all_projects["projects"]:
2003
+ print(f" {p['name']:<20} {p['sessions_count']:>10} {p['latency_speedup_factor']:>9.2f}x")
2004
+ agg = all_projects["aggregate"]
2005
+ print(" " + "-" * 45)
2006
+ print(f" {'AGGREGATE':<20} {agg['sessions_count']:>10} {agg['latency_speedup_factor']:>9.2f}x")
2007
+ return 0
2008
+
2009
+ agg = aggregate_speedup(gcc_dir)
2010
+ by_dev = aggregate_by_developer(gcc_dir)
2011
+ if getattr(args, "as_json", False):
2012
+ print(json.dumps({"project": agg, "per_developer": by_dev}, indent=2))
2013
+ return 0
2014
+ print("DevTorch Speedup Metrics\n")
2015
+ print(f" Sessions: {agg['sessions_count']}")
2016
+ print(f" Latency speedup factor: {agg['latency_speedup_factor']:.2f}x [MEASURED]")
2017
+ print(f" Full history tokens: {agg['total_full_history_tokens']:,}")
2018
+ print(f" Bundle tokens: {agg['total_bundle_tokens']:,}")
2019
+ if by_dev:
2020
+ print("\n Per developer:")
2021
+ for dev_id, dev in sorted(by_dev.items()):
2022
+ print(f" {dev_id}: speedup={dev['latency_speedup_factor']:.2f}x sessions={dev['sessions_count']}")
2023
+ return 0
2024
+
2025
+ # Read session metrics
2026
+ session_dir = gcc_dir / "metrics" / "session"
2027
+ session_files: list[dict] = []
2028
+ if session_dir.exists():
2029
+ for p in session_dir.glob("*.json"):
2030
+ try:
2031
+ data = json.loads(p.read_text(encoding="utf-8"))
2032
+ if isinstance(data, dict):
2033
+ session_files.append(data)
2034
+ except Exception:
2035
+ pass
2036
+
2037
+ # Read sprint metrics
2038
+ sprint_dir = gcc_dir / "metrics" / "sprint"
2039
+ sprint_files: list[dict] = []
2040
+ if sprint_dir.exists():
2041
+ for p in sprint_dir.glob("*.json"):
2042
+ try:
2043
+ data = json.loads(p.read_text(encoding="utf-8"))
2044
+ if isinstance(data, dict):
2045
+ sprint_files.append(data)
2046
+ except Exception:
2047
+ pass
2048
+
2049
+ # Shadow AI detection
2050
+ try:
2051
+ shadow = detect_shadow_ai(repo_root)
2052
+ except Exception:
2053
+ shadow = {"git_commits": 0, "gcc_commits": 0, "unregistered": 0, "shadow_ratio": 0.0, "flagged": False}
2054
+
2055
+ # DHS computation (best-effort from available data)
2056
+ try:
2057
+ components = DHSComponents(
2058
+ commit_health=1.0 if (shadow.get("gcc_commits", 0) > 0) else 0.0,
2059
+ sensitivity_health=1.0,
2060
+ capability_health=0.0,
2061
+ variance_health=1.0,
2062
+ )
2063
+ dhs_score = compute_dhs(components)
2064
+ dhs_label = dhs_to_label(dhs_score)
2065
+ except Exception:
2066
+ dhs_score = 0.0
2067
+ dhs_label = "UNAVAILABLE"
2068
+
2069
+ # Sprint 25: complex-problem delivery-time baseline
2070
+ metrics_dir = gcc_dir / "metrics"
2071
+ show_delivery_time = (
2072
+ getattr(args, "delivery_time", False)
2073
+ or "complex_problem_delivery_time_baseline_min" in calibration
2074
+ )
2075
+ delivery_summary = None
2076
+ if show_delivery_time:
2077
+ delivery_summary = compute_delivery_time_summary(metrics_dir, calibration)
2078
+
2079
+ # Aggregate session data
2080
+ total_tokens_used = sum(s.get("tokens_used", 0) for s in session_files)
2081
+ total_tokens_saved = sum(s.get("tokens_saved", 0) for s in session_files)
2082
+ avg_coverage = (
2083
+ sum(s.get("coverage", 0.0) for s in session_files) / len(session_files)
2084
+ if session_files else 0.0
2085
+ )
2086
+ # Aggregate confidence distribution
2087
+ conf_dist: dict[str, int] = {}
2088
+ for s in session_files:
2089
+ for tier, cnt in (s.get("confidence_distribution") or {}).items():
2090
+ conf_dist[tier] = conf_dist.get(tier, 0) + int(cnt)
2091
+
2092
+ # Aggregate sprint data
2093
+ avg_mcs = (
2094
+ sum(sp.get("mcs_score", 0.0) for sp in sprint_files) / len(sprint_files)
2095
+ if sprint_files else 0.0
2096
+ )
2097
+ total_i3_violations = sum(sp.get("i3_violations", 0) for sp in sprint_files)
2098
+ total_collisions_prevented = sum(sp.get("collisions_prevented", 0) for sp in sprint_files)
2099
+
2100
+ # Auditability: fraction of git commits that are GCC-registered
2101
+ git_commits = shadow.get("git_commits", 0)
2102
+ gcc_commits = shadow.get("gcc_commits", 0)
2103
+ auditability = gcc_commits / git_commits if git_commits > 0 else 1.0
2104
+
2105
+ disclosure = (
2106
+ "\nNOTE: MCS/DHS weights are DevTorch design choices pending empirical calibration."
2107
+ )
2108
+
2109
+ if getattr(args, "as_json", False):
2110
+ output = {
2111
+ "period_days": period_days,
2112
+ "calibration": calibration,
2113
+ "developer": {
2114
+ "tokens_used": {"value": total_tokens_used, "tier": "MEASURED"},
2115
+ "tokens_saved": {"value": total_tokens_saved, "tier": "CALIBRATED"},
2116
+ "coverage_pct": {"value": round(avg_coverage * 100, 1), "tier": "MEASURED"},
2117
+ "confidence_distribution": {"value": conf_dist, "tier": "MEASURED"},
2118
+ },
2119
+ "tech_lead": {
2120
+ "mcs_trend": {"value": round(avg_mcs, 4), "tier": "CALIBRATED"},
2121
+ "i3_violations": {"value": total_i3_violations, "tier": "MEASURED"},
2122
+ "collisions_prevented": {"value": total_collisions_prevented, "tier": "CALIBRATED"},
2123
+ },
2124
+ "ciso": {
2125
+ "dhs_score": {"value": round(dhs_score, 4), "label": dhs_label, "tier": "CALIBRATED"},
2126
+ "shadow_ai_ratio": {"value": round(shadow.get("shadow_ratio", 0.0), 4), "flagged": shadow.get("flagged", False), "tier": "MEASURED"},
2127
+ "auditability_pct": {"value": round(auditability * 100, 1), "tier": "MEASURED"},
2128
+ },
2129
+ "shadow_ai": shadow,
2130
+ "disclosure": disclosure.strip(),
2131
+ }
2132
+ if delivery_summary:
2133
+ output["tech_lead"]["complex_problem_delivery_time"] = {
2134
+ "current_min": delivery_summary["complex_problem_delivery_time_current_min"],
2135
+ "baseline_min": delivery_summary["complex_problem_delivery_time_baseline_min"],
2136
+ "delta_vs_baseline_min": delivery_summary["delta_vs_baseline_min"],
2137
+ "tier": delivery_summary["tier"],
2138
+ }
2139
+ print(json.dumps(output, indent=2))
2140
+ return 0
2141
+
2142
+ # Formatted output
2143
+ print(f"DevTorch Metrics Summary (period: {period_days}d)\n")
2144
+ print("=== Developer ===")
2145
+ print(f" Tokens used: {total_tokens_used:,} [MEASURED]")
2146
+ print(f" Tokens saved: {total_tokens_saved:,} [CALIBRATED]")
2147
+ print(f" Coverage: {avg_coverage * 100:.1f}% [MEASURED]")
2148
+ if conf_dist:
2149
+ conf_str = " ".join(f"{t}:{c}" for t, c in sorted(conf_dist.items()))
2150
+ print(f" Confidence: {conf_str} [MEASURED]")
2151
+ else:
2152
+ print(" Confidence: (no session data) [UNAVAILABLE]")
2153
+
2154
+ print("\n=== Tech Lead ===")
2155
+ print(f" MCS trend: {avg_mcs:.4f} [CALIBRATED]")
2156
+ print(f" I3 violations: {total_i3_violations} [MEASURED]")
2157
+ print(f" Collisions prevented: {total_collisions_prevented} [CALIBRATED]")
2158
+ if delivery_summary:
2159
+ current = delivery_summary["complex_problem_delivery_time_current_min"]
2160
+ baseline = delivery_summary["complex_problem_delivery_time_baseline_min"]
2161
+ delta = delivery_summary["delta_vs_baseline_min"]
2162
+ print(
2163
+ f" Complex problem delivery time: {current:.1f} min "
2164
+ f"(baseline {baseline:.1f}, delta {delta:+.1f}) {delivery_summary['tier']}"
2165
+ )
2166
+
2167
+ print("\n=== CISO ===")
2168
+ print(f" DHS score: {dhs_score:.4f} ({dhs_label}) [CALIBRATED]")
2169
+ shadow_ratio = shadow.get("shadow_ratio", 0.0)
2170
+ flagged_str = " *** FLAGGED ***" if shadow.get("flagged") else ""
2171
+ print(f" Shadow AI ratio: {shadow_ratio:.4f}{flagged_str} [MEASURED]")
2172
+ print(f" Auditability: {auditability * 100:.1f}% [MEASURED]")
2173
+
2174
+ print(disclosure)
2175
+ return 0
2176
+
2177
+
2178
+ def build_parser() -> argparse.ArgumentParser:
2179
+ epilog = (
2180
+ "Common commands:\n"
2181
+ " devtorch init start tracking this project\n"
2182
+ " devtorch status show the current repository state\n"
2183
+ " devtorch doctor run health checks\n"
2184
+ " devtorch today daily status dashboard\n"
2185
+ " devtorch log --tail 20 recent commit history\n"
2186
+ " devtorch metrics --period 1d recent activity metrics\n"
2187
+ " devtorch topics list list topics with captured reasoning\n"
2188
+ " devtorch concept list-all list all concepts from all sources\n"
2189
+ " devtorch concept learned list concepts from learning interactions\n"
2190
+ " devtorch reasoning history --topic <topic> reasoning history by topic\n"
2191
+ " devtorch sync all [--interval N] bidirectional sync with MCP server\n"
2192
+ "\n"
2193
+ "Run 'devtorch <command> --help' for more information on a command."
2194
+ )
2195
+ parser = argparse.ArgumentParser(
2196
+ prog="devtorch",
2197
+ description="DevTorch — governance for AI-assisted codebases.",
2198
+ epilog=epilog,
2199
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2200
+ )
2201
+ parser.add_argument(
2202
+ "-C",
2203
+ "--path",
2204
+ default=".",
2205
+ help="Path to the repository root (default: current directory).",
2206
+ )
2207
+
2208
+ subparsers = parser.add_subparsers(dest="command", required=False)
2209
+
2210
+ # One-command setup: init + hooks + MCP + CLAUDE.md
2211
+ p_setup = subparsers.add_parser(
2212
+ "setup",
2213
+ help="One-command setup: init + MCP server config + IDE-specific wiring.",
2214
+ )
2215
+ p_setup.add_argument(
2216
+ "--target",
2217
+ choices=["claude-code", "cursor", "antigravity", "auto"],
2218
+ default=None,
2219
+ help="IDE to configure (default: auto-detect).",
2220
+ )
2221
+ p_setup.add_argument(
2222
+ "--skip-claude-md",
2223
+ dest="skip_claude_md",
2224
+ action="store_true",
2225
+ help="Skip writing the DevTorch section to CLAUDE.md.",
2226
+ )
2227
+ p_setup.add_argument(
2228
+ "--skip-agents-md",
2229
+ dest="skip_agents_md",
2230
+ action="store_true",
2231
+ help="Skip writing the DevTorch section to AGENTS.md (Google Antigravity).",
2232
+ )
2233
+ p_setup.set_defaults(func=cmd_setup)
2234
+
2235
+ p_init = subparsers.add_parser("init", help="Initialize the .GCC layout.")
2236
+ p_init.add_argument(
2237
+ "--template",
2238
+ choices=["python", "typescript", "react", "go", "infra"],
2239
+ default=None,
2240
+ help="Apply a project template with default concepts and governance policy.",
2241
+ )
2242
+ p_init.set_defaults(func=cmd_init)
2243
+
2244
+ p_serve = subparsers.add_parser(
2245
+ "serve",
2246
+ help="Start the unified local dev server (proxy + dashboard + broadcast + MCP).",
2247
+ )
2248
+ p_serve.add_argument(
2249
+ "--host", default="127.0.0.1", help="Host interface to bind (default: 127.0.0.1)."
2250
+ )
2251
+ p_serve.add_argument(
2252
+ "--port", type=int, default=8765, help="Port to bind (default: 8765)."
2253
+ )
2254
+ p_serve.add_argument(
2255
+ "--gcc-path", default=None, help="Path to project root containing .GCC (default: auto-detect)."
2256
+ )
2257
+ p_serve.set_defaults(func=_cmd_serve)
2258
+
2259
+ p_welcome = subparsers.add_parser(
2260
+ "welcome", help="Show a quick getting-started guide."
2261
+ )
2262
+ p_welcome.set_defaults(func=cmd_welcome)
2263
+
2264
+ p_ask = subparsers.add_parser(
2265
+ "ask", help="Hybrid grep + semantic search over .GCC/ and project files (S27)."
2266
+ )
2267
+ p_ask.add_argument("query", help="Search query.")
2268
+ p_ask.add_argument(
2269
+ "--top-n", type=int, default=5, dest="top_n",
2270
+ help="Maximum number of results to show (default: 5)."
2271
+ )
2272
+ p_ask.add_argument(
2273
+ "--semantic", action="store_true", dest="semantic",
2274
+ help="Force semantic search (falls back to keyword search if sentence-transformers is unavailable)."
2275
+ )
2276
+ p_ask.add_argument(
2277
+ "--no-semantic", action="store_true", dest="no_semantic",
2278
+ help="Force grep-only search."
2279
+ )
2280
+ p_ask.set_defaults(func=cmd_ask)
2281
+
2282
+ p_today = subparsers.add_parser(
2283
+ "today", help="Daily status dashboard (status + doctor + log + metrics)."
2284
+ )
2285
+ p_today.add_argument(
2286
+ "--tail", type=int, default=20, help="Recent log lines to show (default: 20)."
2287
+ )
2288
+ p_today.set_defaults(func=cmd_today)
2289
+
2290
+ p_commit = subparsers.add_parser("commit", help="Record a reasoning commit (Sprint 0 happy path).")
2291
+ p_commit.add_argument("-m", "--message", help="Commit message.", default="")
2292
+ p_commit.add_argument(
2293
+ "--concepts",
2294
+ help="Comma-separated concept tags (e.g. auth,security). Phase 5: developer-agent handoff.",
2295
+ default="",
2296
+ )
2297
+ p_commit.set_defaults(func=cmd_commit)
2298
+
2299
+ # Phase 5: reasoning chain
2300
+ p_chain = subparsers.add_parser(
2301
+ "chain", help="Show the full reasoning chain: human commit -> agent session -> calls -> learnings -> outcomes (Phase 5)."
2302
+ )
2303
+ p_chain.add_argument(
2304
+ "--concept",
2305
+ default=None,
2306
+ help="Filter chains by concept name (matches commit concepts and message).",
2307
+ )
2308
+ p_chain.add_argument(
2309
+ "--max", type=int, default=5, dest="max_chains",
2310
+ help="Maximum number of chains to show (default: 5).",
2311
+ )
2312
+ p_chain.set_defaults(func=cmd_chain)
2313
+
2314
+ p_doctor = subparsers.add_parser("doctor", help="Validate .GCC layout and event log integrity.")
2315
+ p_doctor.add_argument(
2316
+ "--ci",
2317
+ action="store_true",
2318
+ help="CI mode: enforce --min-dhs-score and --critical-concepts thresholds.",
2319
+ )
2320
+ p_doctor.add_argument(
2321
+ "--min-dhs-score",
2322
+ type=float,
2323
+ default=0.0,
2324
+ help="Minimum Deployment Health Score (0-1) for CI mode to pass.",
2325
+ )
2326
+ p_doctor.add_argument(
2327
+ "--critical-concepts",
2328
+ type=str,
2329
+ default="",
2330
+ help="Comma-separated list of concepts that must exist in Θ for CI mode to pass.",
2331
+ )
2332
+ p_doctor.set_defaults(func=cmd_doctor)
2333
+
2334
+ p_branch = subparsers.add_parser("branch", help="Create a new branch.")
2335
+ p_branch.add_argument("name", help="New branch name.")
2336
+ p_branch.add_argument("--from-ref", help="Commit ID to start from (default: current tip).", default=None)
2337
+ p_branch.set_defaults(func=cmd_branch)
2338
+
2339
+ p_history = subparsers.add_parser("history", help="Show commit history.")
2340
+ p_history.add_argument("--branch", help="Branch name (default: current branch).", default=None)
2341
+ p_history.set_defaults(func=cmd_history)
2342
+
2343
+ p_merge = subparsers.add_parser("merge", help="Fast-forward merge source into target branch.")
2344
+ p_merge.add_argument("source", help="Source branch name.")
2345
+ p_merge.add_argument(
2346
+ "--target",
2347
+ help="Target branch name (default: current branch).",
2348
+ default=None,
2349
+ )
2350
+ p_merge.set_defaults(func=cmd_merge)
2351
+
2352
+ p_context = subparsers.add_parser("context", help="Build a MECW-aware context bundle.")
2353
+ p_context.add_argument(
2354
+ "-k",
2355
+ type=int,
2356
+ required=True,
2357
+ help="Approximate token budget for the bundle.",
2358
+ )
2359
+ p_context.add_argument(
2360
+ "--branch",
2361
+ help="Branch name to build context from (default: current branch).",
2362
+ default=None,
2363
+ )
2364
+ p_context.add_argument(
2365
+ "--policy",
2366
+ help="Optional sensitivity policy name (e.g. 'default', 'no-private'); "
2367
+ "recorded in the bundle for future enforcement.",
2368
+ default="default",
2369
+ )
2370
+ p_context.add_argument(
2371
+ "--explain",
2372
+ action="store_true",
2373
+ help="Include bundle file path in the output for debugging.",
2374
+ )
2375
+ p_context.set_defaults(func=cmd_context)
2376
+
2377
+ # Sprint 3: sensitivity subcommands
2378
+ p_sens = subparsers.add_parser("sensitivity", help="Manage sensitivity events.")
2379
+ sens_sub = p_sens.add_subparsers(dest="sens_command", required=True)
2380
+
2381
+ p_sens_add = sens_sub.add_parser("add", help="Record a sensitivity event.")
2382
+ p_sens_add.add_argument("--source", required=True, help="Source node identifier.")
2383
+ p_sens_add.add_argument("--concept", required=True, help="Target concept name.")
2384
+ p_sens_add.add_argument("--confidence", type=float, required=True, help="Confidence [0, 1].")
2385
+ p_sens_add.add_argument(
2386
+ "--disclosure",
2387
+ default="PUBLIC",
2388
+ choices=["PUBLIC", "PROTECTED", "PRIVATE"],
2389
+ help="Disclosure level (default: PUBLIC).",
2390
+ )
2391
+ p_sens_add.add_argument("--counterfactuals", default="", help="Counterfactual description.")
2392
+ p_sens_add.add_argument("--numerical", type=float, default=None, help="Optional numerical sensitivity value.")
2393
+ p_sens_add.add_argument("--uncertainty", type=float, default=None, help="Optional ±uncertainty for numerical value.")
2394
+ p_sens_add.add_argument("-m", "--message", default="", help="Human-readable annotation.")
2395
+ p_sens_add.set_defaults(func=cmd_sensitivity_add)
2396
+
2397
+ p_sens_list = sens_sub.add_parser("list", help="List sensitivity events.")
2398
+ p_sens_list.add_argument("--concept", default=None, help="Filter by concept name.")
2399
+ p_sens_list.add_argument("--disclosure", default=None, choices=["PUBLIC", "PROTECTED", "PRIVATE"],
2400
+ help="Filter by disclosure level.")
2401
+ p_sens_list.add_argument("--source-node", default=None, dest="source_node",
2402
+ help="Filter by source node identifier.")
2403
+ p_sens_list.set_defaults(func=cmd_sensitivity_list)
2404
+
2405
+ p_sens_filter = sens_sub.add_parser("filter", help="Filter sensitivities and show Θ vector.")
2406
+ p_sens_filter.add_argument("--concept", default=None, help="Filter by concept name.")
2407
+ p_sens_filter.add_argument("--disclosure", default=None, choices=["PUBLIC", "PROTECTED", "PRIVATE"],
2408
+ help="Filter by disclosure level.")
2409
+ p_sens_filter.add_argument("--source-node", default=None, dest="source_node",
2410
+ help="Filter by source node identifier.")
2411
+ p_sens_filter.set_defaults(func=cmd_sensitivity_filter)
2412
+
2413
+ # Sprint 3: capability subcommands
2414
+ p_cap = subparsers.add_parser("capability", help="Manage Ωᵢ capability declarations.")
2415
+ cap_sub = p_cap.add_subparsers(dest="cap_command", required=True)
2416
+
2417
+ p_cap_set = cap_sub.add_parser("set", help="Set the Ωᵢ capability declaration.")
2418
+ p_cap_set.add_argument("--agent-id", required=True, dest="agent_id", help="Agent identifier.")
2419
+ p_cap_set.add_argument("--lipschitz-bound", type=float, required=True, dest="lipschitz_bound",
2420
+ help="Empirical Lipschitz bound L̂.")
2421
+ p_cap_set.add_argument("--calibration-hash", required=True, dest="calibration_hash",
2422
+ help="SHA-256 hex digest of calibration dataset.")
2423
+ p_cap_set.add_argument("--embedding-model", required=True, dest="embedding_model",
2424
+ help="Embedding model name/version.")
2425
+ p_cap_set.add_argument("--calibration-date", required=True, dest="calibration_date",
2426
+ help="ISO date of calibration run (YYYY-MM-DD).")
2427
+ p_cap_set.add_argument("--temperature", type=float, required=True, dest="temperature",
2428
+ help="Sampling temperature used during calibration [0, 2].")
2429
+ p_cap_set.add_argument("--lipschitz-p95", type=float, default=None, dest="lipschitz_p95",
2430
+ help="Optional 95th-percentile Lipschitz estimate.")
2431
+ p_cap_set.add_argument("--expiry-days", type=int, default=90, dest="expiry_days",
2432
+ help="Days until calibration expires (default: 90).")
2433
+ p_cap_set.set_defaults(func=cmd_capability_set)
2434
+
2435
+ p_cap_show = cap_sub.add_parser("show", help="Display the current Ωᵢ declaration.")
2436
+ p_cap_show.set_defaults(func=cmd_capability_show)
2437
+
2438
+ p_cap_val = cap_sub.add_parser("validate", help="Validate Ωᵢ against A1 gate conditions.")
2439
+ p_cap_val.add_argument("--l-max", type=float, default=L_MAX_DEFAULT, dest="l_max",
2440
+ help=f"Lipschitz bound threshold L_max (default: {L_MAX_DEFAULT}).")
2441
+ p_cap_val.set_defaults(func=cmd_capability_validate)
2442
+
2443
+ # S28: capability role subcommands
2444
+ p_cap_role = cap_sub.add_parser("role", help="Manage agent roles and role policy (S28).")
2445
+ role_sub = p_cap_role.add_subparsers(dest="role_command", required=True)
2446
+
2447
+ p_role_list = role_sub.add_parser("list", help="List defined agent roles.")
2448
+ p_role_list.set_defaults(func=cmd_capability_role_list)
2449
+
2450
+ p_role_show = role_sub.add_parser("show", help="Show a role definition.")
2451
+ p_role_show.add_argument("role_id", help="Role identifier.")
2452
+ p_role_show.set_defaults(func=cmd_capability_role_show)
2453
+
2454
+ p_role_set = role_sub.add_parser("set", help="Create or update a role.")
2455
+ p_role_set.add_argument("role_id", help="Role identifier.")
2456
+ p_role_set.add_argument("--name", default=None, help="Human-readable role name.")
2457
+ p_role_set.add_argument("--allowed", default="", help="Comma-separated allowed concepts.")
2458
+ p_role_set.add_argument("--disallowed", default="", help="Comma-separated disallowed concepts.")
2459
+ p_role_set.add_argument("--required", default="", help="Comma-required capability fields.")
2460
+ p_role_set.add_argument("--max-temperature", type=float, default=None, dest="max_temperature",
2461
+ help="Maximum allowed temperature for this role.")
2462
+ p_role_set.set_defaults(func=cmd_capability_role_set)
2463
+
2464
+ p_role_policy = role_sub.add_parser("policy", help="Show the current role policy.")
2465
+ p_role_policy.set_defaults(func=cmd_capability_role_policy)
2466
+
2467
+ p_role_default = role_sub.add_parser("apply-default-policy", help="Apply the built-in default role policy.")
2468
+ p_role_default.set_defaults(func=cmd_capability_role_apply_default_policy)
2469
+
2470
+ # Sprint 4: lock, unlock, invariant, concept, variance
2471
+ p_lock = subparsers.add_parser("lock", help="Acquire consensus lock.")
2472
+ p_lock.add_argument("--reason", default="", help="Reason for locking.")
2473
+ p_lock.add_argument("--peers", type=int, default=None, dest="peers", help="Required peer count (optional).")
2474
+ p_lock.set_defaults(func=cmd_lock)
2475
+
2476
+ p_unlock = subparsers.add_parser("unlock", help="Release consensus lock (I1 checked).")
2477
+ p_unlock.set_defaults(func=cmd_unlock)
2478
+
2479
+ p_inv = subparsers.add_parser("invariant", help="Run invariant checks.")
2480
+ inv_sub = p_inv.add_subparsers(dest="inv_command", required=True)
2481
+ p_inv_check = inv_sub.add_parser("check", help="Run I1/I3 invariant engine.")
2482
+ p_inv_check.add_argument("--operation", default="merge", help="Operation context (default: merge).")
2483
+ p_inv_check.add_argument("--concepts", default="", help="Comma-separated concept names for I3.")
2484
+ p_inv_check.set_defaults(func=cmd_invariant_check)
2485
+
2486
+ # S29: alias `invariants` for the invariant command (plural form used in pre-commit hooks).
2487
+ p_inv_plural = subparsers.add_parser("invariants", help="Alias for `devtorch invariant`.")
2488
+ inv_plural_sub = p_inv_plural.add_subparsers(dest="inv_command", required=True)
2489
+ p_inv_plural_check = inv_plural_sub.add_parser("check", help="Run I1/I3 invariant engine.")
2490
+ p_inv_plural_check.add_argument("--operation", default="merge", help="Operation context (default: merge).")
2491
+ p_inv_plural_check.add_argument("--concepts", default="", help="Comma-separated concept names for I3.")
2492
+ p_inv_plural_check.set_defaults(func=cmd_invariant_check)
2493
+
2494
+ p_concept = subparsers.add_parser("concept", help="Manage concept definitions (I3 semantic grounding).")
2495
+ concept_sub = p_concept.add_subparsers(dest="concept_command", required=True)
2496
+ p_concept_add = concept_sub.add_parser("add", help="Add a concept definition.")
2497
+ p_concept_add.add_argument("name", help="Concept name.")
2498
+ p_concept_add.add_argument("definition", help="Concept definition text.")
2499
+ p_concept_add.set_defaults(func=cmd_concept_add)
2500
+ p_concept_list = concept_sub.add_parser("list", help="List registered concepts.")
2501
+ p_concept_list.set_defaults(func=cmd_concept_list)
2502
+ p_concept_danger = concept_sub.add_parser(
2503
+ "danger", help="Detect concepts with abnormally low learning success rates (Phase 3)."
2504
+ )
2505
+ p_concept_danger.add_argument(
2506
+ "--threshold", type=float, default=0.4,
2507
+ help="Success rate threshold below which a concept is flagged (default: 0.4).",
2508
+ )
2509
+ p_concept_danger.add_argument(
2510
+ "--min-samples", type=int, default=3,
2511
+ help="Minimum learnings required to consider a concept (default: 3).",
2512
+ )
2513
+ p_concept_danger.set_defaults(func=cmd_concept_danger)
2514
+
2515
+ # Phase 5B: concept list-all, learned, related subcommands
2516
+ p_concept_list_all = concept_sub.add_parser(
2517
+ "list-all", help="List all concepts from every source (theta, sensitivity, learnings, commits, calls)."
2518
+ )
2519
+ p_concept_list_all.set_defaults(func=cmd_concept_list_all)
2520
+
2521
+ p_concept_learned = concept_sub.add_parser(
2522
+ "learned", help="List concepts learned from interactions with learning stats."
2523
+ )
2524
+ p_concept_learned.set_defaults(func=cmd_concept_learned)
2525
+
2526
+ p_concept_related = concept_sub.add_parser(
2527
+ "related", help="List concepts related to a given topic."
2528
+ )
2529
+ p_concept_related.add_argument("--topic", required=True, help="Topic name.")
2530
+ p_concept_related.set_defaults(func=cmd_concept_related)
2531
+
2532
+ p_var = subparsers.add_parser("variance", help="Variance calibration and rolling monitor (f_max, A2/I2).")
2533
+ var_sub = p_var.add_subparsers(dest="var_command", required=True)
2534
+ p_var_cal = var_sub.add_parser("calibrate", help="Run calibration and persist report.")
2535
+ p_var_cal.add_argument("--variances", default="", help="Comma-separated agent_id=sigma_sq (e.g. a1=0.1,a2=0.2).")
2536
+ p_var_cal.set_defaults(func=cmd_variance_calibrate)
2537
+ p_var_mon = var_sub.add_parser("monitor", help="Run rolling monitor; emit VARIANCE_ALERT if f_max drops or is 0.")
2538
+ p_var_mon.add_argument("--variances", default="", help="Optional: comma-separated agent_id=sigma_sq for live re-check.")
2539
+ p_var_mon.set_defaults(func=cmd_variance_monitor)
2540
+
2541
+ # Sprint 5: rep, sis-tc, prompt, deltaf, rdp, disclosure
2542
+ p_rep = subparsers.add_parser("rep", help="REP envelope and local ledger (S5).")
2543
+ rep_sub = p_rep.add_subparsers(dest="rep_command", required=True)
2544
+ p_rep_append = rep_sub.add_parser("append", help="Append REP envelope to ledger.")
2545
+ p_rep_append.add_argument("--agent-id", required=True, dest="agent_id", help="Agent ID.")
2546
+ p_rep_append.add_argument("--round", type=int, required=True, dest="round_id", help="Round ID.")
2547
+ p_rep_append.add_argument("--payload", default="{}", help="JSON payload.")
2548
+ p_rep_append.add_argument("--trust", type=float, default=0.5, help="Trust score [0,1].")
2549
+ p_rep_append.set_defaults(func=cmd_rep_append)
2550
+ p_rep_checksum = rep_sub.add_parser("checksum", help="Replay checksum of ledger.")
2551
+ p_rep_checksum.set_defaults(func=cmd_rep_checksum)
2552
+
2553
+ p_sis = subparsers.add_parser("sis-tc", help="SIS-TC evaluation (A3).")
2554
+ sis_sub = p_sis.add_subparsers(dest="sis_command", required=True)
2555
+ p_sis_run = sis_sub.add_parser("run", help="Run SIS-TC eval; persist report.")
2556
+ p_sis_run.add_argument("--corpus", default=None, help="Path to corpus JSONL (default: .GCC/sis/sis_tc_corpus.jsonl).")
2557
+ p_sis_run.set_defaults(func=cmd_sis_tc_run)
2558
+
2559
+ p_prompt = subparsers.add_parser("prompt", help="RACP system prompt artifact (A3).")
2560
+ prompt_sub = p_prompt.add_subparsers(dest="prompt_command", required=True)
2561
+ p_prompt_set = prompt_sub.add_parser("set", help="Store RACP_SYSTEM_PROMPT_v2.1.")
2562
+ p_prompt_set.add_argument("--file", default=None, help="Read content from file.")
2563
+ p_prompt_set.add_argument("content", nargs="?", default="", help="Prompt content (or use --file).")
2564
+ p_prompt_set.add_argument("--version", default="v2.1")
2565
+ p_prompt_set.set_defaults(func=cmd_prompt_set)
2566
+ p_prompt_show = prompt_sub.add_parser("show", help="Show stored prompt.")
2567
+ p_prompt_show.set_defaults(func=cmd_prompt_show)
2568
+
2569
+ p_deltaf = subparsers.add_parser("deltaf", help="Δf estimation (A4).")
2570
+ deltaf_sub = p_deltaf.add_subparsers(dest="deltaf_command", required=True)
2571
+ p_deltaf_run = deltaf_sub.add_parser("run", help="Run Δf estimation; persist report.")
2572
+ p_deltaf_run.add_argument("--expiry-days", type=int, default=90, dest="expiry_days")
2573
+ p_deltaf_run.set_defaults(func=cmd_deltaf_run)
2574
+
2575
+ p_rdp = subparsers.add_parser("rdp", help="RDP accountant (A4).")
2576
+ rdp_sub = p_rdp.add_subparsers(dest="rdp_command", required=True)
2577
+ p_rdp_spend = rdp_sub.add_parser("spend", help="Spend epsilon for round.")
2578
+ p_rdp_spend.add_argument("round_id", type=int, help="Round ID.")
2579
+ p_rdp_spend.add_argument("epsilon", type=float, help="Epsilon cost.")
2580
+ p_rdp_spend.set_defaults(func=cmd_rdp_spend)
2581
+ p_rdp_status = rdp_sub.add_parser("status", help="Show read_only status.")
2582
+ p_rdp_status.set_defaults(func=cmd_rdp_status)
2583
+
2584
+ p_disc = subparsers.add_parser("disclosure", help="[PRIVATE] suppression and padding (A4).")
2585
+ disc_sub = p_disc.add_subparsers(dest="disclosure_command", required=True)
2586
+ p_disc_redact = disc_sub.add_parser("redact", help="Redact [PRIVATE] with padding.")
2587
+ p_disc_redact.add_argument("text", nargs="?", default="", help="Input text (or stdin).")
2588
+ p_disc_redact.add_argument("--padding", type=int, default=32, help="Padding length.")
2589
+ p_disc_redact.set_defaults(func=cmd_disclosure_redact)
2590
+
2591
+ # Sprint 6: debug
2592
+ p_debug = subparsers.add_parser("debug", help="Debug views (timeline, branch comparison, invariant trace).")
2593
+ debug_sub = p_debug.add_subparsers(dest="debug_command", required=True)
2594
+ p_debug_timeline = debug_sub.add_parser("timeline", help="Event timeline, optionally filtered.")
2595
+ p_debug_timeline.add_argument(
2596
+ "--filter",
2597
+ default=None,
2598
+ help="Comma-separated event types (e.g. CAPABILITY_DEGRADED,VARIANCE_ALERT,QUARANTINE,PRIVACY_BUDGET_EXHAUSTED).",
2599
+ )
2600
+ p_debug_timeline.set_defaults(func=cmd_debug_timeline)
2601
+ p_debug_branch = debug_sub.add_parser("branch-compare", help="Compare two branches (history + Θ).")
2602
+ p_debug_branch.add_argument("branch1", help="First branch name.")
2603
+ p_debug_branch.add_argument("branch2", help="Second branch name.")
2604
+ p_debug_branch.set_defaults(func=cmd_debug_branch_compare)
2605
+ p_debug_inv = debug_sub.add_parser("invariant-trace", help="Show invariant failures and actionable fixes.")
2606
+ p_debug_inv.set_defaults(func=cmd_debug_invariant_trace)
2607
+
2608
+ # Sprint 9: status command
2609
+ p_status = subparsers.add_parser("status", help="Show repository status (branch, commits, Θ hot zones, invariants, lock).")
2610
+ p_status.set_defaults(func=cmd_status)
2611
+
2612
+ # Sprint 9: log command
2613
+ p_log = subparsers.add_parser("log", help="Print .GCC/log.md to stdout.")
2614
+ p_log.add_argument("--tail", type=int, default=None, metavar="N",
2615
+ help="Print last N lines only (default: print all).")
2616
+ p_log.add_argument("--branch", default=None, metavar="B",
2617
+ help="Branch filter (note: log.md is global; filter is noted but not applied).")
2618
+ p_log.set_defaults(func=cmd_log)
2619
+
2620
+ # Sprint 9: signing subcommand
2621
+ p_signing = subparsers.add_parser("signing", help="Optional HMAC-SHA256 signing of events.log.jsonl.")
2622
+ signing_sub = p_signing.add_subparsers(dest="signing_command", required=True)
2623
+
2624
+ p_signing_enable = signing_sub.add_parser("enable", help="Enable event log signing (creates .GCC/signing.key if needed).")
2625
+ p_signing_enable.set_defaults(func=cmd_signing_enable)
2626
+
2627
+ p_signing_disable = signing_sub.add_parser("disable", help="Disable event log signing (keeps existing key).")
2628
+ p_signing_disable.set_defaults(func=cmd_signing_disable)
2629
+
2630
+ p_signing_verify = signing_sub.add_parser("verify", help="Verify signatures in events.log.jsonl.")
2631
+ p_signing_verify.set_defaults(func=cmd_signing_verify)
2632
+
2633
+ # Sprint 30: audit subcommand
2634
+ build_audit_parser(subparsers)
2635
+
2636
+ # Sprint 19: projects subcommand
2637
+ p_projects = subparsers.add_parser("projects", help="Manage the machine-level DevTorch project registry.")
2638
+ projects_sub = p_projects.add_subparsers(dest="projects_command", required=True)
2639
+
2640
+ p_projects_add = projects_sub.add_parser("add", help="Add a project to the registry.")
2641
+ p_projects_add.add_argument("path", help="Path to the project root.")
2642
+ p_projects_add.add_argument("--name", default=None, help="Optional project name.")
2643
+ p_projects_add.add_argument("--developer-id", default=None, help="Optional developer ID override.")
2644
+ p_projects_add.add_argument(
2645
+ "--configure-mcp",
2646
+ action="store_true",
2647
+ dest="configure_mcp",
2648
+ help="Also write per-project MCP configs (.claude/hooks.json, .cursor/mcp.json).",
2649
+ )
2650
+ p_projects_add.set_defaults(func=cmd_projects_add)
2651
+
2652
+ p_projects_list = projects_sub.add_parser("list", help="List registered projects.")
2653
+ p_projects_list.set_defaults(func=cmd_projects_list)
2654
+
2655
+ p_projects_remove = projects_sub.add_parser("remove", help="Remove a project from the registry.")
2656
+ p_projects_remove.add_argument("path", help="Path to the project root.")
2657
+ p_projects_remove.set_defaults(func=cmd_projects_remove)
2658
+
2659
+ p_projects_sync = projects_sub.add_parser("sync", help="Refresh last_synced for all registered projects.")
2660
+ p_projects_sync.set_defaults(func=cmd_projects_sync)
2661
+
2662
+ # S7: proxy subcommand (server in devtorch_core/proxy/)
2663
+ p_proxy = subparsers.add_parser("proxy", help="LLM proxy management (S7).")
2664
+ proxy_sub = p_proxy.add_subparsers(dest="proxy_command", required=True)
2665
+
2666
+ p_proxy_start = proxy_sub.add_parser("start", help="Start the LLM proxy server in the foreground.")
2667
+ p_proxy_start.add_argument("--port", type=int, default=8765, help="Port to listen on (default: 8765).")
2668
+ p_proxy_start.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1).")
2669
+ p_proxy_start.set_defaults(func=cmd_proxy_start)
2670
+
2671
+ p_proxy_stop = proxy_sub.add_parser("stop", help="Print instructions to stop the proxy.")
2672
+ p_proxy_stop.set_defaults(func=cmd_proxy_stop)
2673
+
2674
+ p_proxy_install = proxy_sub.add_parser("install", help="Install proxy as a system service.")
2675
+ p_proxy_install.set_defaults(func=cmd_proxy_install)
2676
+
2677
+ p_proxy_status = proxy_sub.add_parser("status", help="Check if the proxy is running on :8765.")
2678
+ p_proxy_status.set_defaults(func=cmd_proxy_status)
2679
+
2680
+ # S26: daemon subcommand
2681
+ p_daemon = subparsers.add_parser(
2682
+ "daemon", help="Background file watcher and server (S26)."
2683
+ )
2684
+ daemon_sub = p_daemon.add_subparsers(dest="daemon_command", required=True)
2685
+
2686
+ p_daemon_start = daemon_sub.add_parser("start", help="Start the background daemon.")
2687
+ p_daemon_start.add_argument(
2688
+ "--run-server", action="store_true", dest="run_server",
2689
+ help="Also start the unified DevTorch server in the daemon process.",
2690
+ )
2691
+ p_daemon_start.add_argument(
2692
+ "--host", default="127.0.0.1", help="Host to bind the unified server to (default: 127.0.0.1)."
2693
+ )
2694
+ p_daemon_start.add_argument(
2695
+ "--port", type=int, default=8765, help="Port to bind the unified server to (default: 8765)."
2696
+ )
2697
+ p_daemon_start.set_defaults(func=cmd_daemon_start)
2698
+
2699
+ p_daemon_stop = daemon_sub.add_parser("stop", help="Stop the background daemon.")
2700
+ p_daemon_stop.set_defaults(func=cmd_daemon_stop)
2701
+
2702
+ p_daemon_status = daemon_sub.add_parser("status", help="Show daemon status.")
2703
+ p_daemon_status.set_defaults(func=cmd_daemon_status)
2704
+
2705
+ p_daemon_logs = daemon_sub.add_parser("logs", help="Print the daemon log.")
2706
+ p_daemon_logs.add_argument(
2707
+ "--lines", type=int, default=50, help="Number of lines to show (default: 50)."
2708
+ )
2709
+ p_daemon_logs.set_defaults(func=cmd_daemon_logs)
2710
+
2711
+ # S28: session subcommand
2712
+ p_session = subparsers.add_parser(
2713
+ "session", help="Multi-agent session orchestration (S28)."
2714
+ )
2715
+ session_sub = p_session.add_subparsers(dest="session_command", required=True)
2716
+
2717
+ p_session_start = session_sub.add_parser("start", help="Start a new multi-agent session.")
2718
+ p_session_start.add_argument("--name", required=True, help="Session name.")
2719
+ p_session_start.add_argument("--problem", required=True, help="Problem statement to solve.")
2720
+ p_session_start.add_argument(
2721
+ "--agent", action="append", dest="agents", default=[],
2722
+ help="Agent assignment AGENT_ID=ROLE (repeatable).",
2723
+ )
2724
+ p_session_start.set_defaults(func=cmd_session_start)
2725
+
2726
+ p_session_list = session_sub.add_parser("list", help="List sessions.")
2727
+ p_session_list.set_defaults(func=cmd_session_list)
2728
+
2729
+ p_session_show = session_sub.add_parser("show", help="Show session details.")
2730
+ p_session_show.add_argument("session_id", help="Session ID.")
2731
+ p_session_show.set_defaults(func=cmd_session_show)
2732
+
2733
+ p_session_plan = session_sub.add_parser("plan", help="Decompose the session into subtasks.")
2734
+ p_session_plan.add_argument("session_id", help="Session ID.")
2735
+ p_session_plan.add_argument("--problem", default=None, help="Override the problem statement.")
2736
+ p_session_plan.set_defaults(func=cmd_session_plan)
2737
+
2738
+ p_session_next = session_sub.add_parser("next", help="Show the next ready subtask.")
2739
+ p_session_next.add_argument("session_id", help="Session ID.")
2740
+ p_session_next.set_defaults(func=cmd_session_next)
2741
+
2742
+ p_session_mark = session_sub.add_parser("mark", help="Update subtask status.")
2743
+ p_session_mark.add_argument("session_id", help="Session ID.")
2744
+ p_session_mark.add_argument("--subtask", required=True, dest="subtask_id", help="Subtask ID.")
2745
+ p_session_mark.add_argument(
2746
+ "--status", required=True,
2747
+ choices=["pending", "in_progress", "completed", "blocked", "failed"],
2748
+ help="New subtask status.",
2749
+ )
2750
+ p_session_mark.set_defaults(func=cmd_session_mark)
2751
+
2752
+ p_session_close = session_sub.add_parser("close", help="Close a session.")
2753
+ p_session_close.add_argument("session_id", help="Session ID.")
2754
+ p_session_close.set_defaults(func=cmd_session_close)
2755
+
2756
+ p_session_disagree = session_sub.add_parser("disagree", help="Record a disagreement between agents.")
2757
+ p_session_disagree.add_argument("session_id", help="Session ID.")
2758
+ p_session_disagree.add_argument("--concept", required=True, help="Concept under disagreement.")
2759
+ p_session_disagree.add_argument(
2760
+ "--position", action="append", dest="positions", default=[],
2761
+ help="Agent position AGENT=POSITION:CONFIDENCE (repeatable).",
2762
+ )
2763
+ p_session_disagree.set_defaults(func=cmd_session_disagree)
2764
+
2765
+ p_session_resolve = session_sub.add_parser("resolve", help="Resolve a disagreement.")
2766
+ p_session_resolve.add_argument("disagreement_id", help="Disagreement ID.")
2767
+ p_session_resolve.add_argument("--decision", default=None, help="Human decision to record.")
2768
+ p_session_resolve.set_defaults(func=cmd_session_resolve)
2769
+
2770
+ p_session_disagreements = session_sub.add_parser("disagreements", help="List disagreements.")
2771
+ p_session_disagreements.add_argument("--session", dest="session_id", default=None, help="Filter by session ID.")
2772
+ p_session_disagreements.set_defaults(func=cmd_session_disagreements)
2773
+
2774
+ # S28: simulate subcommand
2775
+ p_simulate = subparsers.add_parser(
2776
+ "simulate", help="Run a what-if simulation against a session (S28)."
2777
+ )
2778
+ p_simulate.add_argument("--session", required=True, dest="session_id", help="Session ID.")
2779
+ p_simulate.add_argument(
2780
+ "--assume", action="append", dest="assumptions", default=[],
2781
+ help="Alternate assumption KEY=VALUE (repeatable).",
2782
+ )
2783
+ p_simulate.add_argument("--json", action="store_true", help="Also print the raw result as JSON.")
2784
+ p_simulate.set_defaults(func=cmd_simulate)
2785
+
2786
+ # S29: pre-commit subcommand
2787
+ p_pre_commit = subparsers.add_parser(
2788
+ "pre-commit", help="Install and run pre-commit hooks (S29)."
2789
+ )
2790
+ pre_commit_sub = p_pre_commit.add_subparsers(dest="pre_commit_command", required=True)
2791
+
2792
+ p_pre_commit_install = pre_commit_sub.add_parser("install", help="Install the DevTorch pre-commit hook.")
2793
+ p_pre_commit_install.set_defaults(func=cmd_pre_commit_install)
2794
+
2795
+ p_pre_commit_run = pre_commit_sub.add_parser("run", help="Run DevTorch pre-commit checks manually.")
2796
+ p_pre_commit_run.set_defaults(func=cmd_pre_commit_run)
2797
+
2798
+ # S29: pr-report subcommand
2799
+ p_pr_report = subparsers.add_parser(
2800
+ "pr-report", help="Post a reasoning-delta comment on a GitHub PR and check merge gate (S29)."
2801
+ )
2802
+ p_pr_report.add_argument("--base", required=True, help="Base branch name.")
2803
+ p_pr_report.add_argument("--head", required=True, help="Head branch name.")
2804
+ p_pr_report.add_argument("--repo-owner", required=True, help="GitHub repository owner.")
2805
+ p_pr_report.add_argument("--repo-name", required=True, help="GitHub repository name.")
2806
+ p_pr_report.add_argument("--pr-number", required=True, type=int, help="Pull request number.")
2807
+ p_pr_report.add_argument("--token", required=True, help="GitHub API token.")
2808
+ p_pr_report.add_argument("--critical-concepts", default="", help="Comma-separated critical concept names.")
2809
+ p_pr_report.add_argument("--min-mcs", type=float, default=0.0, help="Minimum MCS score for merge gate (default: 0).")
2810
+ p_pr_report.set_defaults(func=cmd_pr_report)
2811
+
2812
+ # S17a: calibrate subcommand
2813
+ p_calibrate = subparsers.add_parser("calibrate", help="Calibrate DevTorch metrics baselines.")
2814
+ p_calibrate.add_argument(
2815
+ "--set",
2816
+ dest="set_value",
2817
+ metavar="KEY=VALUE",
2818
+ help="Set a single calibration value.",
2819
+ )
2820
+ p_calibrate.set_defaults(func=cmd_calibrate)
2821
+
2822
+ # S17a: metrics subcommand
2823
+ p_metrics = subparsers.add_parser("metrics", help="Show DevTorch metrics summary.")
2824
+ p_metrics.add_argument(
2825
+ "--period",
2826
+ default="30d",
2827
+ help="Time period (e.g. 30d, 7d). Default: 30d.",
2828
+ )
2829
+ p_metrics.add_argument(
2830
+ "--json",
2831
+ action="store_true",
2832
+ dest="as_json",
2833
+ help="Output raw JSON.",
2834
+ )
2835
+ p_metrics.add_argument(
2836
+ "--tokens",
2837
+ action="store_true",
2838
+ help="Show token usage/savings breakdown (S20).",
2839
+ )
2840
+ p_metrics.add_argument(
2841
+ "--speedup",
2842
+ action="store_true",
2843
+ help="Show latency speedup breakdown (S20).",
2844
+ )
2845
+ p_metrics.add_argument(
2846
+ "--all-projects",
2847
+ action="store_true",
2848
+ dest="all_projects",
2849
+ help="Aggregate metrics across all projects in the registry (S19).",
2850
+ )
2851
+ p_metrics.add_argument(
2852
+ "--delivery-time",
2853
+ action="store_true",
2854
+ dest="delivery_time",
2855
+ help="Show complex-problem delivery-time baseline and current estimate (S25).",
2856
+ )
2857
+ p_metrics.set_defaults(func=cmd_metrics)
2858
+
2859
+ # Sprint 19: reasoning subcommand
2860
+ reasoning_parser = subparsers.add_parser("reasoning", help="Query agent reasoning")
2861
+ reasoning_sub = reasoning_parser.add_subparsers(dest="reasoning_cmd")
2862
+ rshow = reasoning_sub.add_parser("show", help="Show agent reasoning for a concept")
2863
+ rshow.add_argument("--concept", required=True)
2864
+ rshow.add_argument("--agent", dest="agent_id", default=None)
2865
+ rshow.add_argument("--tokens", type=int, default=4000)
2866
+ rshow.set_defaults(func=cmd_reasoning_show)
2867
+
2868
+ # Phase 5B: reasoning history subcommand
2869
+ rhistory = reasoning_sub.add_parser("history", help="Show reasoning history for a topic or concept.")
2870
+ rhistory.add_argument("--topic", default=None, help="Topic name (expands to multiple concepts).")
2871
+ rhistory.add_argument("--concept", default=None, help="Single concept name (alternative to topic).")
2872
+ rhistory.add_argument("--max-entries", type=int, default=50, dest="max_entries", help="Max total entries (default: 50).")
2873
+ rhistory.add_argument("--agent", dest="agent_id", default=None, help="Filter by agent ID.")
2874
+ rhistory.add_argument("--tokens", type=int, default=4000, help="Per-concept token budget (default: 4000).")
2875
+ rhistory.set_defaults(func=cmd_reasoning_history)
2876
+
2877
+ # Phase 5B: topics subcommand
2878
+ topics_parser = subparsers.add_parser("topics", help="Manage and query topic taxonomy.")
2879
+ topics_sub = topics_parser.add_subparsers(dest="topics_command", required=True)
2880
+
2881
+ p_topics_list = topics_sub.add_parser("list", help="List topics with captured reasoning.")
2882
+ p_topics_list.add_argument("--all", action="store_true", help="Show full taxonomy including topics with no data.")
2883
+ p_topics_list.set_defaults(func=cmd_topics_list)
2884
+
2885
+ p_topics_show = topics_sub.add_parser("show", help="Show details for a specific topic.")
2886
+ p_topics_show.add_argument("name", help="Topic name.")
2887
+ p_topics_show.set_defaults(func=cmd_topics_show)
2888
+
2889
+ p_topics_add = topics_sub.add_parser("add", help="Add a custom topic.")
2890
+ p_topics_add.add_argument("name", help="Topic name.")
2891
+ p_topics_add.add_argument("--concepts", required=True, help="Comma-separated concept names.")
2892
+ p_topics_add.set_defaults(func=cmd_topics_add)
2893
+
2894
+ p_topics_remove = topics_sub.add_parser("remove", help="Remove a custom topic.")
2895
+ p_topics_remove.add_argument("name", help="Topic name.")
2896
+ p_topics_remove.set_defaults(func=cmd_topics_remove)
2897
+
2898
+ p_topics_derive = topics_sub.add_parser("derive", help="Auto-derive topics from concept co-occurrence.")
2899
+ p_topics_derive.add_argument("--force", action="store_true", help="Force re-derivation even if cached.")
2900
+ p_topics_derive.set_defaults(func=cmd_topics_derive)
2901
+
2902
+ # S27: templates subcommand (reasoning template library)
2903
+ p_templates = subparsers.add_parser(
2904
+ "templates",
2905
+ help="Reasoning template library and custom template extension (S27).",
2906
+ )
2907
+ templates_sub = p_templates.add_subparsers(dest="templates_command", required=True)
2908
+
2909
+ p_templates_list = templates_sub.add_parser("list", help="List built-in and custom templates.")
2910
+ p_templates_list.set_defaults(func=cmd_templates_list)
2911
+
2912
+ p_templates_apply = templates_sub.add_parser("apply", help="Render a template with variables.")
2913
+ p_templates_apply.add_argument("name", help="Template name.")
2914
+ p_templates_apply.add_argument(
2915
+ "--var",
2916
+ dest="variables",
2917
+ action="append",
2918
+ default=[],
2919
+ help="Template variable KEY=VALUE (repeatable).",
2920
+ )
2921
+ p_templates_apply.add_argument(
2922
+ "--output",
2923
+ default=None,
2924
+ help="Output path for the rendered template (default: .GCC/templates/rendered/<name>.md).",
2925
+ )
2926
+ p_templates_apply.set_defaults(func=cmd_templates_apply)
2927
+
2928
+ p_templates_init = templates_sub.add_parser("init", help="Create the custom template extension directory.")
2929
+ p_templates_init.set_defaults(func=cmd_templates_init)
2930
+
2931
+ # S24: reasoning-plus subcommand
2932
+ rp_parser = subparsers.add_parser("reasoning-plus", help="Manage Reasoning Plus settings")
2933
+ rp_sub = rp_parser.add_subparsers(dest="reasoning_plus_cmd")
2934
+ rp_enable = rp_sub.add_parser("enable", help="Enable Reasoning Plus for this project")
2935
+ rp_enable.add_argument("--path", default=".", help="Path to project root (default: .)")
2936
+ rp_enable.set_defaults(func=cmd_reasoning_plus_enable)
2937
+ rp_disable = rp_sub.add_parser("disable", help="Disable Reasoning Plus for this project")
2938
+ rp_disable.add_argument("--path", default=".", help="Path to project root (default: .)")
2939
+ rp_disable.set_defaults(func=cmd_reasoning_plus_disable)
2940
+ rp_status = rp_sub.add_parser("status", help="Show current Reasoning Plus settings and source")
2941
+ rp_status.add_argument("--path", default=".", help="Path to project root (default: .)")
2942
+ rp_status.set_defaults(func=cmd_reasoning_plus_status)
2943
+ rp_config = rp_sub.add_parser("config", help="Update Reasoning Plus settings")
2944
+ rp_config.add_argument("--path", default=".", help="Path to project root (default: .)")
2945
+ rp_config.add_argument("--smart-top-n", type=int, default=None)
2946
+ rp_config.add_argument("--smart-max-lines", type=int, default=None)
2947
+ rp_config.add_argument("--context-k", type=int, default=None)
2948
+ rp_config.add_argument("--require-thinking", type=lambda x: x.lower() in ("1", "true", "yes", "on"), default=None)
2949
+ rp_config.add_argument("--max-keywords", type=int, default=None)
2950
+ rp_config.add_argument("--cache-ttl", type=int, default=None)
2951
+ rp_config.add_argument("--learning-enabled", type=lambda x: x.lower() in ("1", "true", "yes", "on"), default=None)
2952
+ rp_config.add_argument("--learning-top-n", type=int, default=None)
2953
+ rp_config.add_argument("--learning-max-lines", type=int, default=None)
2954
+ rp_config.add_argument("--learning-embedding-backend", type=str, default=None)
2955
+ rp_config.add_argument("--learning-embedding-model", type=str, default=None)
2956
+ rp_config.add_argument("--learning-extraction-strategy", type=str, default=None)
2957
+ rp_config.add_argument("--learning-relevance-cache-ttl", type=int, default=None)
2958
+ rp_config.set_defaults(func=cmd_reasoning_plus_config)
2959
+ rp_reset = rp_sub.add_parser("reset", help="Reset project Reasoning Plus settings to default")
2960
+ rp_reset.add_argument("--path", default=".", help="Path to project root (default: .)")
2961
+ rp_reset.set_defaults(func=cmd_reasoning_plus_reset)
2962
+
2963
+ rp_suggest = rp_sub.add_parser(
2964
+ "cross-project-suggest",
2965
+ help="Suggest relevant learnings from sibling governed projects.",
2966
+ )
2967
+ rp_suggest.add_argument("--path", default=".", help="Path to project root (default: .)")
2968
+ rp_suggest.add_argument("query", help="Query to match against project name/tags and learnings.")
2969
+ rp_suggest.add_argument("--top-n", type=int, default=3, help="Maximum number of learnings to return (default: 3).")
2970
+ rp_suggest.set_defaults(func=cmd_reasoning_plus_cross_project_suggest)
2971
+
2972
+ # Phase 3: learning analytics CLI
2973
+ learning_parser = subparsers.add_parser("learning", help="Per-concept learning statistics and analytics (Phase 3).")
2974
+ learning_sub = learning_parser.add_subparsers(dest="learning_command", required=True)
2975
+ p_learning_stats = learning_sub.add_parser("stats", help="Show learning statistics.")
2976
+ p_learning_stats.add_argument("--by-concept", action="store_true", help="Group statistics by concept.")
2977
+ p_learning_stats.set_defaults(func=cmd_learning_stats)
2978
+
2979
+ sync_parser = subparsers.add_parser("sync", help="Sync REP ledger or team Θ with peers.")
2980
+ sync_parser.add_argument("--interval", type=int, default=0,
2981
+ help="If >0, repeat sync every N seconds (runs until interrupted).")
2982
+ sync_parser.add_argument("--gcc-dir", default=".GCC",
2983
+ help="Path to .GCC directory (default: .GCC in cwd).")
2984
+ sync_sub = sync_parser.add_subparsers(dest="sync_command", required=False)
2985
+
2986
+ # Existing REP sync (default when no subcommand is given)
2987
+ rep_sync_parser = sync_sub.add_parser("rep", help="Sync REP ledger with registered peers.")
2988
+ rep_sync_parser.set_defaults(func=cmd_sync)
2989
+
2990
+ # Team Θ synchronization (S31)
2991
+ theta_sync_parser = sync_sub.add_parser("theta", help="Synchronize team Θ (coordination vector) via cloud storage.")
2992
+ theta_sync_sub = theta_sync_parser.add_subparsers(dest="theta_sync_command", required=True)
2993
+ theta_push = theta_sync_sub.add_parser("push", help="Push local .GCC/theta.json to the org team Θ store.")
2994
+ theta_push.add_argument("--org", required=True, help="DevTorch org ID")
2995
+ theta_push.add_argument("--gcc-dir", default=".GCC", help="Path to .GCC directory (default: .GCC in cwd)")
2996
+ theta_push.add_argument(
2997
+ "--from-learnings", action="store_true",
2998
+ help="Sync learning success rates into Θ before pushing (Phase 4).",
2999
+ )
3000
+ theta_push.set_defaults(func=cmd_sync_theta_push)
3001
+ theta_pull = theta_sync_sub.add_parser("pull", help="Pull the org team Θ store and merge it into local .GCC/theta.json.")
3002
+ theta_pull.add_argument("--org", required=True, help="DevTorch org ID")
3003
+ theta_pull.add_argument("--gcc-dir", default=".GCC", help="Path to .GCC directory (default: .GCC in cwd)")
3004
+ theta_pull.set_defaults(func=cmd_sync_theta_pull)
3005
+
3006
+ # Phase 4: cross-project learning sync
3007
+ learning_sync_parser = sync_sub.add_parser("learning", help="Synchronize learnings across team projects via cloud storage.")
3008
+ learning_sync_sub = learning_sync_parser.add_subparsers(dest="learning_sync_command", required=True)
3009
+ lp_push = learning_sync_sub.add_parser("push", help="Push local learnings to the org team learning store.")
3010
+ lp_push.add_argument("--org", required=True, help="DevTorch org ID")
3011
+ lp_push.add_argument("--gcc-dir", default=".GCC", help="Path to .GCC directory (default: .GCC in cwd)")
3012
+ lp_push.set_defaults(func=cmd_sync_learning_push)
3013
+ lp_pull = learning_sync_sub.add_parser("pull", help="Pull and merge team learnings into the local store.")
3014
+ lp_pull.add_argument("--org", required=True, help="DevTorch org ID")
3015
+ lp_pull.add_argument("--gcc-dir", default=".GCC", help="Path to .GCC directory (default: .GCC in cwd)")
3016
+ lp_pull.set_defaults(func=cmd_sync_learning_pull)
3017
+
3018
+ # Bidirectional sync (PR #32)
3019
+ sync_all_parser = sync_sub.add_parser("all", help="Bidirectional sync with MCP server (git-inspired, incremental + conflict resolution).")
3020
+ sync_all_parser.add_argument("--interval", type=int, default=0, help="Repeat every N minutes (default: 0 = one-shot).")
3021
+ sync_all_parser.add_argument("--dry-run", action="store_true", help="Show what would sync without making changes.")
3022
+ sync_all_parser.add_argument("--verbose", action="store_true", help="Show per-item details.")
3023
+ sync_all_parser.add_argument("--chunk-size", type=int, default=500, help="Max items per batch (default: 500).")
3024
+ sync_all_parser.add_argument("--force-full", action="store_true", help="Treat sync state as empty, trigger full re-sync.")
3025
+ sync_all_parser.set_defaults(func=cmd_sync_all)
3026
+
3027
+ sync_conflicts_parser = sync_sub.add_parser("conflicts", help="List and resolve sync conflicts.")
3028
+ sync_conflicts_parser.add_argument("--all", action="store_true", dest="show_all", help="Include resolved conflicts.")
3029
+ sync_conflicts_parser.add_argument("--severity", default=None, choices=["high", "medium", "low"], help="Filter by severity.")
3030
+ sync_conflicts_parser.set_defaults(func=cmd_sync_conflicts)
3031
+
3032
+ sync_conflicts_resolve_parser = sync_sub.add_parser("conflicts-resolve", help="Resolve a specific sync conflict.")
3033
+ sync_conflicts_resolve_parser.add_argument("conflict_id", help="Conflict ID to resolve.")
3034
+ sync_conflicts_resolve_parser.add_argument("--use-remote", action="store_true", help="Use the remote value instead of local.")
3035
+ sync_conflicts_resolve_parser.add_argument("--dismiss", action="store_true", help="Dismiss without changing anything.")
3036
+ sync_conflicts_resolve_parser.add_argument("--dry-run", action="store_true", help="Show what would happen without applying.")
3037
+ sync_conflicts_resolve_parser.set_defaults(func=cmd_sync_conflicts_resolve)
3038
+
3039
+ sync_parser.set_defaults(func=cmd_sync, sync_command=None)
3040
+
3041
+ watch_parser = subparsers.add_parser("watch", help="Tail coordination events from .GCC/ in real time.")
3042
+ watch_parser.add_argument("--gcc-dir", default=".GCC",
3043
+ help="Path to .GCC directory (default: .GCC in cwd).")
3044
+ watch_parser.set_defaults(func=cmd_watch)
3045
+
3046
+ # S11: mcp-server subcommand
3047
+ mcp_parser = subparsers.add_parser("mcp-server", help="Start the DevTorch MCP server")
3048
+ mcp_parser.add_argument(
3049
+ "--transport", choices=["stdio", "sse"], default="stdio",
3050
+ help="Transport: stdio (default, local) or sse (cloud HTTPS)",
3051
+ )
3052
+ mcp_parser.add_argument("--host", default="0.0.0.0", help="SSE bind host (default: 0.0.0.0)")
3053
+ mcp_parser.add_argument("--port", type=int, default=8080, help="SSE bind port (default: 8080)")
3054
+
3055
+ # Cloud key management
3056
+ cloud_parser = subparsers.add_parser("cloud", help="Cloud MCP server management")
3057
+ cloud_sub = cloud_parser.add_subparsers(dest="cloud_command", required=True)
3058
+ key_parser = cloud_sub.add_parser("key", help="API key management")
3059
+ key_sub = key_parser.add_subparsers(dest="key_command", required=True)
3060
+ key_create = key_sub.add_parser("create", help="Create a new API key")
3061
+ key_create.add_argument("--org", required=True, help="Org ID")
3062
+ key_create.add_argument("--repo", help="Repo ID (optional, adds to allowed_repos)")
3063
+ key_create.add_argument(
3064
+ "--github", metavar="OWNER/REPO",
3065
+ help="Also set DEVTORCH_API_KEY as a GitHub Repository Secret",
3066
+ )
3067
+
3068
+ key_list = key_sub.add_parser("list", help="List API keys for an org")
3069
+ key_list.add_argument("--org", required=True, help="Org ID")
3070
+
3071
+ key_revoke = key_sub.add_parser("revoke", help="Revoke an API key by hash prefix")
3072
+ key_revoke.add_argument("--org", required=True, help="Org ID")
3073
+ key_revoke.add_argument("key_prefix", help="Hex prefix of the key hash to revoke (min 8 chars)")
3074
+ key_revoke.add_argument("--yes", "-y", action="store_true",
3075
+ help="Skip confirmation prompt (required for non-interactive use)")
3076
+
3077
+ key_rotate = key_sub.add_parser("rotate", help="Rotate an API key by prefix (revoke old, create new)")
3078
+ key_rotate.add_argument("--org", required=True, help="Org ID")
3079
+ key_rotate.add_argument("key_prefix", help="Hex prefix of the key hash to rotate (min 8 chars)")
3080
+ key_rotate.add_argument("--yes", "-y", action="store_true",
3081
+ help="Skip confirmation prompt (required for non-interactive use)")
3082
+
3083
+ repo_parser = cloud_sub.add_parser("repo", help="GitHub repository connection management")
3084
+ repo_sub = repo_parser.add_subparsers(dest="repo_command", required=True)
3085
+
3086
+ repo_connect = repo_sub.add_parser("connect", help="Connect a GitHub repository")
3087
+ repo_connect.add_argument("--org", required=True, help="Org ID")
3088
+ repo_connect.add_argument("--repo", required=True, help="DevTorch repo ID (e.g. 'myproject')")
3089
+ repo_connect.add_argument("--github", required=True, metavar="OWNER/REPO",
3090
+ help="GitHub repository (e.g. 'acme/backend')")
3091
+ repo_connect_auth = repo_connect.add_mutually_exclusive_group(required=True)
3092
+ repo_connect_auth.add_argument("--pat", metavar="TOKEN",
3093
+ help="GitHub Personal Access Token")
3094
+ repo_connect_auth.add_argument("--github-app", action="store_true",
3095
+ help="Use GitHub App (server handles via webhook)")
3096
+
3097
+ repo_list = repo_sub.add_parser("list", help="List connected GitHub repositories")
3098
+ repo_list.add_argument("--org", required=True, help="Org ID")
3099
+
3100
+ repo_disconnect = repo_sub.add_parser("disconnect", help="Remove a connected GitHub repository")
3101
+ repo_disconnect.add_argument("--org", required=True, help="Org ID")
3102
+ repo_disconnect.add_argument("--repo", required=True, help="DevTorch repo ID")
3103
+
3104
+ # Cloud client configuration installer
3105
+ setup_parser = cloud_sub.add_parser("setup", help="Deploy the self-hosted Cloudflare Worker MCP server (one-command setup).")
3106
+ setup_parser.add_argument("--env-file", help="Environment file to source")
3107
+ setup_parser.add_argument("--org", help="DevTorch org ID")
3108
+ setup_parser.add_argument("--repo", help="DevTorch repo ID")
3109
+ setup_parser.add_argument("--ide", choices=["opencode", "claude", "cursor", "vscode", "stdio"],
3110
+ help="IDE to configure locally")
3111
+ setup_parser.add_argument("--openai-key", help="OpenAI API key for DRPL embedding backend")
3112
+ setup_parser.add_argument("--non-interactive", action="store_true",
3113
+ help="Run without prompts (requires --org and --repo)")
3114
+ setup_parser.add_argument("--skip-key", action="store_true", help="Skip creating API key")
3115
+ setup_parser.add_argument("--skip-client-config", action="store_true", help="Skip IDE config")
3116
+ setup_parser.add_argument("--dry-run", action="store_true", help="Print command instead of running")
3117
+ setup_parser.set_defaults(func=cmd_cloud_setup)
3118
+
3119
+ client_config_parser = cloud_sub.add_parser(
3120
+ "client-config", help="Install MCP client config for an IDE (opencode, claude, cursor, vscode, stdio)."
3121
+ )
3122
+ client_config_parser.add_argument("ide", choices=["opencode", "claude", "cursor", "vscode", "stdio"], help="IDE target")
3123
+ client_config_parser.add_argument("--org", required=True, help="DevTorch org ID")
3124
+ client_config_parser.add_argument("--repo", required=True, help="DevTorch repo ID")
3125
+ client_config_parser.add_argument("--api-key", required=True, help="API key from 'devtorch cloud key create'")
3126
+ client_config_parser.add_argument(
3127
+ "--domain", default=None, help="Cloudflare Worker domain (default: DEVTORCH_MCP_DOMAIN env var)"
3128
+ )
3129
+ client_config_parser.add_argument(
3130
+ "--path", default=".", help="Project root to install config into (default: current directory)"
3131
+ )
3132
+ client_config_parser.add_argument(
3133
+ "--use-env-key", action="store_true",
3134
+ help="claude/stdio targets: write ${DEVTORCH_API_KEY} into .mcp.json instead of "
3135
+ "the literal key (Claude Code expands env vars; export DEVTORCH_API_KEY yourself)",
3136
+ )
3137
+ client_config_parser.set_defaults(func=cmd_cloud_client_config)
3138
+
3139
+ # Cloud reasoning sync (Task 6)
3140
+ sync_parser = cloud_sub.add_parser(
3141
+ "sync", help="Push local .GCC commits to the cloud MCP store (one-way)."
3142
+ )
3143
+ sync_parser.add_argument(
3144
+ "--from-now", action="store_true", dest="from_now",
3145
+ help="Mark all existing local commits as synced without pushing them; "
3146
+ "only commits made after this point will sync (go-forward adoption).",
3147
+ )
3148
+ sync_parser.add_argument("--path", default=".", help="Project root (default: current directory)")
3149
+
3150
+ # S11: hooks subcommand
3151
+ p_hooks = subparsers.add_parser("hooks", help="Manage DevTorch hooks for Claude Code and git (S11).")
3152
+ hooks_sub = p_hooks.add_subparsers(dest="hooks_command", required=True)
3153
+
3154
+ p_hooks_install = hooks_sub.add_parser("install", help="Install .claude/hooks.json and git commit-msg hook.")
3155
+ p_hooks_install.set_defaults(hooks_command="install")
3156
+
3157
+ p_hooks_status = hooks_sub.add_parser("status", help="Show which DevTorch hooks are installed.")
3158
+ p_hooks_status.set_defaults(hooks_command="status")
3159
+
3160
+ p_hooks_git_commit = hooks_sub.add_parser("git-commit", help="Git commit-msg hook entry point.")
3161
+ p_hooks_git_commit.add_argument("commit_msg_file", help="Path to the git commit message file.")
3162
+ p_hooks_git_commit.set_defaults(hooks_command="git-commit")
3163
+
3164
+ p_hooks_git_capture = hooks_sub.add_parser(
3165
+ "git-capture",
3166
+ help="Git capture hook entry point for post-commit, post-merge, post-checkout, post-rewrite.",
3167
+ )
3168
+ p_hooks_git_capture.add_argument(
3169
+ "event",
3170
+ choices=["post-commit", "post-merge", "post-checkout", "post-rewrite", "conflict"],
3171
+ help="Git hook event to capture.",
3172
+ )
3173
+ p_hooks_git_capture.add_argument(
3174
+ "args",
3175
+ nargs="*",
3176
+ help="Extra positional arguments passed by the git hook (e.g. squash flag, prev/new HEAD).",
3177
+ )
3178
+ p_hooks_git_capture.set_defaults(hooks_command="git-capture")
3179
+
3180
+ p_hooks_run = hooks_sub.add_parser("run", help="Run a Claude Code hook (reads JSON from stdin).")
3181
+ p_hooks_run.add_argument("hook_name", choices=["pre-tool-use", "post-tool-use", "session-end"])
3182
+ p_hooks_run.set_defaults(hooks_command="run")
3183
+
3184
+ # S16: alert subcommand
3185
+ p_alert = subparsers.add_parser("alert", help="Send a test governance alert to configured channels (Slack, Teams, Jira, Linear, PagerDuty).")
3186
+ p_alert.add_argument("--title", default="DevTorch test alert", help="Alert title.")
3187
+ p_alert.add_argument("--body", default="This is a test alert from the DevTorch CLI.", help="Alert body.")
3188
+ p_alert.add_argument("--severity", choices=["info", "warning", "critical"], default="warning", help="Alert severity.")
3189
+ p_alert.add_argument("--event-type", default="TEST_ALERT", help="Event type label.")
3190
+ p_alert.add_argument("--channel", choices=["slack", "teams", "jira", "linear", "pagerduty"], help="Send only to this channel (requires env vars for that channel).")
3191
+ p_alert.set_defaults(func=cmd_alert)
3192
+
3193
+ return parser
3194
+
3195
+
3196
+ def _set_github_secret(owner_repo: str, secret_name: str, secret_value: str) -> None:
3197
+ import os
3198
+ import urllib.request
3199
+ import json as _json
3200
+
3201
+ token = os.environ.get("GITHUB_TOKEN", "")
3202
+ if not token:
3203
+ print("Warning: GITHUB_TOKEN not set; skipping GitHub secret creation.")
3204
+ return
3205
+ owner, repo = owner_repo.split("/", 1)
3206
+
3207
+ url = f"https://api.github.com/repos/{owner}/{repo}/actions/secrets/public-key"
3208
+ req = urllib.request.Request(
3209
+ url,
3210
+ headers={
3211
+ "Authorization": f"token {token}",
3212
+ "Accept": "application/vnd.github+json",
3213
+ },
3214
+ )
3215
+ with urllib.request.urlopen(req, timeout=10) as resp:
3216
+ pub_key_data = _json.loads(resp.read())
3217
+
3218
+ try:
3219
+ import nacl.public
3220
+ from base64 import b64decode, b64encode
3221
+ pub_key = nacl.public.PublicKey(b64decode(pub_key_data["key"]))
3222
+ sealed = nacl.public.SealedBox(pub_key).encrypt(secret_value.encode())
3223
+ encrypted = b64encode(sealed).decode()
3224
+ except ImportError:
3225
+ print("Warning: PyNaCl not installed; cannot encrypt GitHub secret. Install with: pip install PyNaCl")
3226
+ return
3227
+
3228
+ payload = _json.dumps({
3229
+ "encrypted_value": encrypted,
3230
+ "key_id": pub_key_data["key_id"],
3231
+ }).encode()
3232
+ put_url = f"https://api.github.com/repos/{owner}/{repo}/actions/secrets/{secret_name}"
3233
+ put_req = urllib.request.Request(
3234
+ put_url, data=payload, method="PUT",
3235
+ headers={
3236
+ "Authorization": f"token {token}",
3237
+ "Accept": "application/vnd.github+json",
3238
+ "Content-Type": "application/json",
3239
+ },
3240
+ )
3241
+ with urllib.request.urlopen(put_req, timeout=10):
3242
+ pass
3243
+ print(f"GitHub secret {secret_name} set on {owner_repo}.")
3244
+
3245
+
3246
+ def cmd_sync(args: argparse.Namespace) -> int:
3247
+ import time
3248
+ from pathlib import Path
3249
+ from devtorch_core.rep import REPLedger
3250
+ from devtorch_core.rep_network.node import NodeRegistry
3251
+ from devtorch_core.rep_network.sync import sync_all_peers
3252
+
3253
+ if getattr(args, "sync_command", None) == "theta":
3254
+ theta_cmd = getattr(args, "theta_sync_command", None)
3255
+ if theta_cmd == "push":
3256
+ return cmd_sync_theta_push(args)
3257
+ if theta_cmd == "pull":
3258
+ return cmd_sync_theta_pull(args)
3259
+ print("error: unknown theta sync command", file=sys.stderr)
3260
+ return 1
3261
+
3262
+ gcc_dir = Path(args.gcc_dir)
3263
+ ledger = REPLedger(gcc_dir / "rep")
3264
+ registry = NodeRegistry(gcc_dir / "rep")
3265
+
3266
+ def _do_sync():
3267
+ results = sync_all_peers(ledger, registry)
3268
+ if not results:
3269
+ print("No peers configured.")
3270
+ return
3271
+ for r in results:
3272
+ status = "✓" if r.success else "✗"
3273
+ print(f" {status} {r.peer_node_id} ({r.peer_url}): "
3274
+ f"pushed={r.pushed_count} pulled={r.pulled_count}"
3275
+ + (f" errors={r.errors}" if r.errors else ""))
3276
+
3277
+ if args.interval > 0:
3278
+ print(f"Syncing every {args.interval}s — Ctrl-C to stop.")
3279
+ try:
3280
+ while True:
3281
+ _do_sync()
3282
+ time.sleep(args.interval)
3283
+ except KeyboardInterrupt:
3284
+ print("\nSync stopped.")
3285
+ else:
3286
+ _do_sync()
3287
+ return 0
3288
+
3289
+
3290
+ def cmd_sync_theta_push(args: argparse.Namespace) -> int:
3291
+ from pathlib import Path
3292
+ from devtorch_core.cloud.team_sync import push_theta
3293
+
3294
+ gcc_dir = Path(args.gcc_dir).resolve()
3295
+
3296
+ if getattr(args, "from_learnings", False):
3297
+ from devtorch_core.reasoning_plus.learning.theta_learning_bridge import (
3298
+ sync_learnings_to_theta,
3299
+ )
3300
+ bridge_result = sync_learnings_to_theta(gcc_dir, min_samples=3)
3301
+ if bridge_result["concepts_synced"]:
3302
+ print(f"Synced {bridge_result['concepts_synced']} concept(s) from learnings into Θ.")
3303
+ else:
3304
+ print("No concepts with sufficient learnings to sync into Θ.")
3305
+
3306
+ result = push_theta(args.org, gcc_dir)
3307
+ print(f"Pushed {result['concepts_pushed']} concept(s) to team Θ for {result['org_id']}.")
3308
+ return 0
3309
+
3310
+
3311
+ def cmd_sync_learning_push(args: argparse.Namespace) -> int:
3312
+ from pathlib import Path
3313
+ from devtorch_core.cloud.team_sync import push_learnings
3314
+
3315
+ result = push_learnings(args.org, Path(args.gcc_dir).resolve())
3316
+ print(
3317
+ f"Pushed {result['learnings_pushed']} learning(s) to team store for "
3318
+ f"{result['org_id']} (total remote: {result['learnings_total_remote']})."
3319
+ )
3320
+ return 0
3321
+
3322
+
3323
+ def cmd_sync_learning_pull(args: argparse.Namespace) -> int:
3324
+ from pathlib import Path
3325
+ from devtorch_core.cloud.team_sync import pull_learnings
3326
+
3327
+ result = pull_learnings(args.org, Path(args.gcc_dir).resolve())
3328
+ print(
3329
+ f"Pulled {result['learnings_added']} new learning(s) from team store for "
3330
+ f"{result['org_id']} (total local: {result['learnings_total_local']})."
3331
+ )
3332
+ return 0
3333
+
3334
+
3335
+ def cmd_sync_theta_pull(args: argparse.Namespace) -> int:
3336
+ from pathlib import Path
3337
+ from devtorch_core.cloud.team_sync import pull_theta
3338
+
3339
+ result = pull_theta(args.org, Path(args.gcc_dir).resolve())
3340
+ print(f"Pulled team Θ for {result['org_id']}: "
3341
+ f"{result['concepts_after']} concept(s) total "
3342
+ f"(+{result['concepts_added']} from remote).")
3343
+ return 0
3344
+
3345
+
3346
+ def cmd_sync_all(args: argparse.Namespace) -> int:
3347
+ """Bidirectional sync with MCP server (sync_pull → merge → sync_push)."""
3348
+ import time as _time
3349
+ from devtorch_core.cloud.mcp_client import detect_mcp_config, McpClient
3350
+ from devtorch_core.cloud.sync_state import SyncState
3351
+ from devtorch_core.cloud.sync_bundle import build_local_delta, build_server_state, compress, decompress, merge_pull
3352
+ from devtorch_core.cloud.sync_conflicts import ConflictLog
3353
+ from devtorch_core.gcc import GCCRepository
3354
+
3355
+ root = Path(args.path).resolve()
3356
+ config = detect_mcp_config(root)
3357
+ if not config:
3358
+ print("No MCP server configured. Set DEVTORCH_MCP_URL and DEVTORCH_API_KEY or configure .mcp.json.", file=sys.stderr)
3359
+ return 1
3360
+
3361
+ url, api_key = config
3362
+ repo = GCCRepository.at(root)
3363
+ if not repo.is_initialized():
3364
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
3365
+ return 1
3366
+
3367
+ with McpClient(url, api_key, timeout=60.0) as client:
3368
+ gcc_dir = repo.gcc_dir
3369
+ sync_state = SyncState(gcc_dir)
3370
+ conflict_log = ConflictLog(gcc_dir)
3371
+
3372
+ interval = getattr(args, "interval", 0)
3373
+ dry_run = getattr(args, "dry_run", False)
3374
+ verbose = getattr(args, "verbose", False)
3375
+ chunk_size = getattr(args, "chunk_size", 500)
3376
+ force_full = getattr(args, "force_full", False)
3377
+
3378
+ consecutive_failures = 0
3379
+
3380
+ def _do_sync() -> bool:
3381
+ nonlocal consecutive_failures
3382
+ state = sync_state.load()
3383
+ if force_full:
3384
+ state = sync_state.reset(state)
3385
+
3386
+ # Phase 1: PULL
3387
+ if verbose:
3388
+ print(" Pull (server → local)...")
3389
+ remote_view = sync_state.get_remote_state_view(state)
3390
+ try:
3391
+ pull_result_raw = client.call_tool("devtorch_sync_pull", {"sync_state": remote_view, "chunk_size": chunk_size})
3392
+ pull_result = json.loads(pull_result_raw)
3393
+ except Exception as e:
3394
+ print(f" Pull failed: {e}. Skipping push. Will retry.", file=sys.stderr)
3395
+ return False
3396
+
3397
+ server_state = pull_result.get("server_state", {})
3398
+ if pull_result.get("bundle"):
3399
+ pull_delta = decompress(pull_result["bundle"])
3400
+ else:
3401
+ pull_delta = {}
3402
+
3403
+ # Phase 2: MERGE PULL
3404
+ if pull_delta.get("delta"):
3405
+ if dry_run:
3406
+ print(f" Pull (dry-run): would merge {sum(len(v) if isinstance(v, list) else 1 for v in pull_delta['delta'].values())} items")
3407
+ else:
3408
+ merge_result = merge_pull(gcc_dir, pull_delta["delta"], conflict_log)
3409
+ state = sync_state.update_after_pull(state, {
3410
+ "server_state": server_state,
3411
+ "delta": pull_delta["delta"],
3412
+ })
3413
+ sync_state.save(state)
3414
+ if verbose:
3415
+ for k, v in merge_result["counts"].items():
3416
+ if v:
3417
+ print(f" {k}: +{v} new")
3418
+ for c in merge_result["conflicts"]:
3419
+ print(f" \u26a0 CONFLICT: {c.get('type','')} — {c.get('item_id','')}")
3420
+
3421
+ # Phase 3: PUSH
3422
+ if verbose:
3423
+ print(" Push (local → server)...")
3424
+ local_delta = build_local_delta(gcc_dir, server_state)
3425
+ if any(isinstance(v, (list, dict)) and v for v in local_delta.values() if isinstance(v, (list, dict))):
3426
+ if dry_run:
3427
+ sizes = {k: len(v) if isinstance(v, list) else 1 for k, v in local_delta.items() if isinstance(v, (list, dict)) and v}
3428
+ print(f" Push (dry-run): would send {sizes}")
3429
+ else:
3430
+ compressed_bundle = compress(local_delta)
3431
+ try:
3432
+ push_result_raw = client.call_tool("devtorch_sync_push", {"bundle": compressed_bundle})
3433
+ push_result = json.loads(push_result_raw)
3434
+ except Exception as e:
3435
+ print(f" Push failed: {e}. Local is updated but server may not have latest.", file=sys.stderr)
3436
+ return False
3437
+
3438
+ # Update sync state with push results
3439
+ state = sync_state.update_after_push(state, {"delta": local_delta}, push_result.get("server_state", {}))
3440
+ sync_state.save(state)
3441
+ if verbose:
3442
+ for k, v in push_result.get("counts", {}).items():
3443
+ if v:
3444
+ print(f" {k}: +{v}")
3445
+ for c in push_result.get("conflicts", []):
3446
+ print(f" \u26a0 CONFLICT: {c.get('type','')} — {c.get('item_id','')}")
3447
+ else:
3448
+ if verbose:
3449
+ print(" Nothing to push.")
3450
+
3451
+ # Show unresolved conflicts
3452
+ unresolved = conflict_log.count_unresolved()
3453
+ if unresolved:
3454
+ print(f" \u26a0 {unresolved} unresolved conflict(s) — run 'devtorch sync conflicts' for details")
3455
+
3456
+ consecutive_failures = 0
3457
+ return True
3458
+
3459
+ if interval > 0:
3460
+ interval_seconds = interval * 60
3461
+ consecutive_failures = 0
3462
+ print(f"Syncing every {interval} minute(s) — Ctrl-C to stop.")
3463
+ try:
3464
+ while True:
3465
+ print()
3466
+ success = _do_sync()
3467
+ if success:
3468
+ consecutive_failures = 0
3469
+ sleep_time = interval_seconds
3470
+ print(f" Next sync in {interval} minute(s).")
3471
+ else:
3472
+ consecutive_failures += 1
3473
+ sleep_time = min(interval_seconds * (2 ** min(consecutive_failures - 1, 4)), 3600)
3474
+ print(f" Sync failed ({consecutive_failures} consecutive). Retrying in {sleep_time // 60} minute(s)...")
3475
+ _time.sleep(sleep_time)
3476
+ except KeyboardInterrupt:
3477
+ print("\nSync stopped.")
3478
+ else:
3479
+ _do_sync()
3480
+
3481
+ return 0
3482
+
3483
+
3484
+ def cmd_sync_conflicts(args: argparse.Namespace) -> int:
3485
+ """List sync conflicts."""
3486
+ from devtorch_core.gcc import GCCRepository
3487
+ from devtorch_core.cloud.sync_conflicts import ConflictLog
3488
+
3489
+ repo = GCCRepository.at(Path(args.path).resolve())
3490
+ if not repo.is_initialized():
3491
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
3492
+ return 1
3493
+
3494
+ conflict_log = ConflictLog(repo.gcc_dir)
3495
+ show_all = getattr(args, "show_all", False)
3496
+ severity_filter = getattr(args, "severity", None)
3497
+
3498
+ entries = conflict_log.list_all() if show_all else conflict_log.list_unresolved()
3499
+
3500
+ if severity_filter:
3501
+ entries = [e for e in entries if e.get("severity") == severity_filter]
3502
+
3503
+ if not entries:
3504
+ print("No conflicts found.")
3505
+ return 0
3506
+
3507
+ print(f"Sync conflicts ({len(entries)}):\n")
3508
+ for e in entries:
3509
+ status = "\u2713 resolved" if e.get("resolved") else "\u26a0 unresolved"
3510
+ print(f" [{e.get('severity', '?')}] {e.get('type', '?')} — {e.get('item_id', '?')} ({status})")
3511
+ if e.get("local_value"):
3512
+ lv = e["local_value"]
3513
+ print(f" Local: {lv.get('message', lv.get('content', json.dumps(lv)))[:80]}")
3514
+ if e.get("remote_value"):
3515
+ rv = e["remote_value"]
3516
+ print(f" Remote: {rv.get('message', rv.get('content', json.dumps(rv)))[:80]}")
3517
+ print(f" Resolution: {e.get('resolution', 'pending')}")
3518
+ if not e.get("resolved"):
3519
+ print(f" Use: devtorch sync conflicts-resolve {e['conflict_id']} [--use-remote] [--dismiss]")
3520
+ print()
3521
+ return 0
3522
+
3523
+
3524
+ def cmd_sync_conflicts_resolve(args: argparse.Namespace) -> int:
3525
+ """Resolve a sync conflict."""
3526
+ from devtorch_core.gcc import GCCRepository
3527
+ from devtorch_core.cloud.sync_conflicts import ConflictLog
3528
+
3529
+ repo = GCCRepository.at(Path(args.path).resolve())
3530
+ if not repo.is_initialized():
3531
+ print("This directory is not tracking DevTorch yet.", file=sys.stderr)
3532
+ return 1
3533
+
3534
+ conflict_log = ConflictLog(repo.gcc_dir)
3535
+ conflict_id = args.conflict_id
3536
+ use_remote = getattr(args, "use_remote", False)
3537
+ dismiss = getattr(args, "dismiss", False)
3538
+ dry_run = getattr(args, "dry_run", False)
3539
+
3540
+ # First check the conflict exists
3541
+ all_entries = conflict_log.list_all()
3542
+ found = None
3543
+ for e in all_entries:
3544
+ if e.get("conflict_id") == conflict_id:
3545
+ found = e
3546
+ break
3547
+
3548
+ if not found:
3549
+ print(f"Conflict '{conflict_id}' not found.", file=sys.stderr)
3550
+ return 1
3551
+
3552
+ if dry_run:
3553
+ rv = found.get("remote_value", {})
3554
+ lv = found.get("local_value", {})
3555
+ print(f"Conflict '{conflict_id}':")
3556
+ print(f" Type: {found.get('type', '?')}")
3557
+ print(f" Current resolution: {found.get('resolution', 'pending')}")
3558
+ if use_remote:
3559
+ print(f" Would apply REMOTE: {rv.get('message', rv.get('content', json.dumps(rv)))[:80]}")
3560
+ elif dismiss:
3561
+ print(f" Would dismiss (keep local).")
3562
+ else:
3563
+ print(f" Would keep LOCAL (default). Add --use-remote to override.")
3564
+ return 0
3565
+
3566
+ result = conflict_log.resolve(conflict_id, use_remote=use_remote, dismiss=dismiss)
3567
+ if result is None:
3568
+ print(f"Conflict '{conflict_id}' not found.", file=sys.stderr)
3569
+ return 1
3570
+
3571
+ print(f"Conflict '{conflict_id}' resolved: {result.get('resolution', 'completed')}.")
3572
+ return 0
3573
+
3574
+
3575
+ def cmd_cloud_setup(args: argparse.Namespace) -> int:
3576
+ from devtorch_core.cloud.setup import run_cloud_setup
3577
+
3578
+ try:
3579
+ result = run_cloud_setup(
3580
+ env_file=args.env_file,
3581
+ org=args.org,
3582
+ repo=args.repo,
3583
+ ide=args.ide,
3584
+ openai_key=args.openai_key,
3585
+ non_interactive=args.non_interactive,
3586
+ skip_key=args.skip_key,
3587
+ skip_client_config=args.skip_client_config,
3588
+ dry_run=args.dry_run,
3589
+ )
3590
+ except (FileNotFoundError, ValueError) as exc:
3591
+ print(f"error: {exc}", file=sys.stderr)
3592
+ return 1
3593
+
3594
+ if args.dry_run:
3595
+ print(" ".join(result["args"]))
3596
+ return 0
3597
+ return int(result.get("returncode", "0"))
3598
+
3599
+
3600
+ def cmd_watch(args: argparse.Namespace) -> int:
3601
+ """Tail the DevTorch event stream (SSE broadcaster)."""
3602
+ import sys
3603
+ from pathlib import Path
3604
+ from devtorch_core.broadcast.broadcaster import EventBroadcaster
3605
+ from devtorch_core.broadcast.watcher import GCCWatcher
3606
+
3607
+ gcc_dir = Path(args.gcc_dir)
3608
+ if not gcc_dir.exists():
3609
+ print(f"Error: {gcc_dir} does not exist. Run devtorch init first.", file=sys.stderr)
3610
+ return 1
3611
+
3612
+ broadcaster = EventBroadcaster()
3613
+ q = broadcaster.subscribe()
3614
+ watcher = GCCWatcher(gcc_dir, broadcaster, poll_interval=0.2)
3615
+ watcher.start()
3616
+ print(f"Watching {gcc_dir}/events.log.jsonl for coordination events... (Ctrl-C to stop)")
3617
+ import json as _json
3618
+ import time as _time
3619
+ try:
3620
+ while True:
3621
+ _time.sleep(0.1)
3622
+ while not q.empty():
3623
+ event = q.get_nowait()
3624
+ print(_json.dumps(event, indent=None))
3625
+ except KeyboardInterrupt:
3626
+ print("\nStopped.")
3627
+ finally:
3628
+ watcher.stop()
3629
+ return 0
3630
+
3631
+
3632
+ # ---------------------------------------------------------------------------
3633
+ # S24: Reasoning Plus commands
3634
+ # ---------------------------------------------------------------------------
3635
+
3636
+
3637
+ def _reasoning_plus_store(args: argparse.Namespace) -> Any:
3638
+ from devtorch_core.reasoning_plus.config import SettingsStore
3639
+
3640
+ project_root = Path(args.path).resolve()
3641
+ gcc_repo = None
3642
+ try:
3643
+ gcc_repo = GCCRepository.at(project_root)
3644
+ except Exception:
3645
+ pass
3646
+ gcc_dir = gcc_repo.gcc_dir if gcc_repo and gcc_repo.is_initialized() else None
3647
+ return SettingsStore(gcc_dir)
3648
+
3649
+
3650
+ def cmd_reasoning_plus_enable(args: argparse.Namespace) -> int:
3651
+ store = _reasoning_plus_store(args)
3652
+ if store._project_dir is None:
3653
+ print("error: no initialized .GCC/ repo found. Run devtorch init first.", file=sys.stderr)
3654
+ return 1
3655
+ config, _ = store.load()
3656
+ config = config.with_values(enabled=True)
3657
+ store.save_project(config)
3658
+ print("Reasoning Plus enabled for this project.")
3659
+ return 0
3660
+
3661
+
3662
+ def cmd_reasoning_plus_disable(args: argparse.Namespace) -> int:
3663
+ store = _reasoning_plus_store(args)
3664
+ if store._project_dir is None:
3665
+ print("error: no initialized .GCC/ repo found. Run devtorch init first.", file=sys.stderr)
3666
+ return 1
3667
+ config, _ = store.load()
3668
+ config = config.with_values(enabled=False)
3669
+ store.save_project(config)
3670
+ print("Reasoning Plus disabled for this project.")
3671
+ return 0
3672
+
3673
+
3674
+ def cmd_reasoning_plus_status(args: argparse.Namespace) -> int:
3675
+ store = _reasoning_plus_store(args)
3676
+ config, source = store.load()
3677
+ print(f"Reasoning Plus status")
3678
+ print(f" source: {source}")
3679
+ print(f" enabled: {config.enabled}")
3680
+ print(f" smart_top_n: {config.smart_top_n}")
3681
+ print(f" smart_max_lines: {config.smart_max_lines}")
3682
+ print(f" context_k: {config.context_k}")
3683
+ print(f" require_thinking: {config.require_thinking}")
3684
+ print(f" max_keywords: {config.max_keywords}")
3685
+ print(f" cache_ttl_secs: {config.cache_ttl_secs}")
3686
+ print(f" learning_enabled: {config.learning_enabled}")
3687
+ print(f" learning_top_n: {config.learning_top_n}")
3688
+ print(f" learning_max_lines: {config.learning_max_lines}")
3689
+ print(f" learning_embedding_backend: {config.learning_embedding_backend}")
3690
+ print(f" learning_embedding_model: {config.learning_embedding_model}")
3691
+ print(f" learning_extraction_strategy: {config.learning_extraction_strategy}")
3692
+ print(f" learning_relevance_cache_ttl: {config.learning_relevance_cache_ttl}")
3693
+ return 0
3694
+
3695
+
3696
+ def cmd_reasoning_plus_config(args: argparse.Namespace) -> int:
3697
+ store = _reasoning_plus_store(args)
3698
+ if store._project_dir is None:
3699
+ print("error: no initialized .GCC/ repo found. Run devtorch init first.", file=sys.stderr)
3700
+ return 1
3701
+ config, _ = store.load()
3702
+ overrides: dict[str, Any] = {}
3703
+ if args.smart_top_n is not None:
3704
+ overrides["smart_top_n"] = args.smart_top_n
3705
+ if args.smart_max_lines is not None:
3706
+ overrides["smart_max_lines"] = args.smart_max_lines
3707
+ if args.context_k is not None:
3708
+ overrides["context_k"] = args.context_k
3709
+ if args.require_thinking is not None:
3710
+ overrides["require_thinking"] = args.require_thinking
3711
+ if args.max_keywords is not None:
3712
+ overrides["max_keywords"] = args.max_keywords
3713
+ if args.cache_ttl is not None:
3714
+ overrides["cache_ttl_secs"] = args.cache_ttl
3715
+ if args.learning_enabled is not None:
3716
+ overrides["learning_enabled"] = args.learning_enabled
3717
+ if args.learning_top_n is not None:
3718
+ overrides["learning_top_n"] = args.learning_top_n
3719
+ if args.learning_max_lines is not None:
3720
+ overrides["learning_max_lines"] = args.learning_max_lines
3721
+ if args.learning_embedding_backend is not None:
3722
+ overrides["learning_embedding_backend"] = args.learning_embedding_backend
3723
+ if args.learning_embedding_model is not None:
3724
+ overrides["learning_embedding_model"] = args.learning_embedding_model
3725
+ if args.learning_extraction_strategy is not None:
3726
+ overrides["learning_extraction_strategy"] = args.learning_extraction_strategy
3727
+ if args.learning_relevance_cache_ttl is not None:
3728
+ overrides["learning_relevance_cache_ttl"] = args.learning_relevance_cache_ttl
3729
+ config = config.with_values(**overrides)
3730
+ store.save_project(config)
3731
+ print("Reasoning Plus configuration updated.")
3732
+ return 0
3733
+
3734
+
3735
+ def cmd_reasoning_plus_reset(args: argparse.Namespace) -> int:
3736
+ store = _reasoning_plus_store(args)
3737
+ if store._project_dir is None:
3738
+ print("error: no initialized .GCC/ repo found. Run devtorch init first.", file=sys.stderr)
3739
+ return 1
3740
+ store.reset_project()
3741
+ print("Reasoning Plus project settings reset; will fall back to global/default.")
3742
+ return 0
3743
+
3744
+
3745
+ def cmd_reasoning_plus_cross_project_suggest(args: argparse.Namespace) -> int:
3746
+ """Suggest relevant learnings from sibling governed projects."""
3747
+ from devtorch_core.reasoning_plus.learning import ReasoningPlusLearning
3748
+ from devtorch_core.reasoning_plus import ReasoningPlusConfig
3749
+
3750
+ project_root = Path(args.path).resolve()
3751
+ gcc_dir = project_root / ".GCC"
3752
+ if not gcc_dir.exists():
3753
+ print("error: no initialized .GCC/ repo found. Run devtorch init first.", file=sys.stderr)
3754
+ return 1
3755
+ config = ReasoningPlusConfig(learning_enabled=True)
3756
+ drpl = ReasoningPlusLearning(gcc_dir=gcc_dir, repo_path=project_root, config=config)
3757
+ learnings = drpl.cross_project_suggest(args.query, top_n=args.top_n)
3758
+ if not learnings:
3759
+ print("No relevant cross-project learnings found.")
3760
+ return 0
3761
+ print(f"Found {len(learnings)} relevant cross-project learning(s):")
3762
+ for i, learning in enumerate(learnings, 1):
3763
+ short = learning.short_form(max_chars=200)
3764
+ print(f" {i}. [{learning.type}] {short}")
3765
+ return 0
3766
+
3767
+
3768
+ def main(argv: list[str] | None = None) -> int:
3769
+ parser = build_parser()
3770
+ args = parser.parse_args(argv)
3771
+
3772
+ # Friendly default: show dashboard in an initialized repo, otherwise welcome guide.
3773
+ if args.command is None:
3774
+ repo = GCCRepository.at(Path(args.path).resolve())
3775
+ if repo.is_initialized():
3776
+ return cmd_today(args)
3777
+ return cmd_welcome(args)
3778
+
3779
+ # S11: mcp-server
3780
+ if args.command == "mcp-server":
3781
+ if args.transport == "sse":
3782
+ import os
3783
+ from devtorch_core.mcp.server import _run_with_sse
3784
+ from devtorch_core.storage import create_backend_from_env
3785
+
3786
+ org_id = os.environ.get("DEVTORCH_ORG_ID", "").strip()
3787
+ if not org_id:
3788
+ print("error: DEVTORCH_ORG_ID is required for SSE transport.", file=sys.stderr)
3789
+ return 1
3790
+
3791
+ try:
3792
+ auth_backend = create_backend_from_env(prefix=org_id)
3793
+ except (KeyError, ValueError) as exc:
3794
+ print(f"error: storage configuration: {exc}", file=sys.stderr)
3795
+ return 1
3796
+
3797
+ def repo_factory(req_org: str, repo_id: str):
3798
+ from devtorch_core.gcc import GCCRepository
3799
+ from pathlib import Path
3800
+ backend = create_backend_from_env(prefix=f"{req_org}/{repo_id}/.GCC")
3801
+ return GCCRepository(root=Path(f"/tmp/{req_org}/{repo_id}"), backend=backend)
3802
+
3803
+ _run_with_sse(
3804
+ repo_factory=repo_factory,
3805
+ auth_backend=auth_backend,
3806
+ host=args.host,
3807
+ port=args.port,
3808
+ )
3809
+ else:
3810
+ from devtorch_core.mcp.server import main as mcp_main
3811
+ mcp_main()
3812
+ return 0
3813
+
3814
+ if args.command == "cloud":
3815
+ import os
3816
+ import json
3817
+ import secrets
3818
+ import hashlib
3819
+ import datetime
3820
+ from devtorch_core.storage import create_backend_from_env
3821
+
3822
+ # setup, client-config, and sync do not need the admin backend; they are local.
3823
+ if args.cloud_command == "setup":
3824
+ return args.func(args)
3825
+ if args.cloud_command == "client-config":
3826
+ return args.func(args)
3827
+ if args.cloud_command == "sync":
3828
+ from devtorch_core.cloud.sync import sync_commits
3829
+
3830
+ root = Path(args.path).resolve()
3831
+ from_now = getattr(args, "from_now", False)
3832
+
3833
+ if from_now:
3834
+ # Pure cursor bookkeeping — no network I/O, no credentials.
3835
+ try:
3836
+ result = sync_commits(root / ".GCC", "", "", from_now=True)
3837
+ except ValueError as exc:
3838
+ print(f"error: {exc}", file=sys.stderr)
3839
+ return 1
3840
+ print(
3841
+ f"Cursor set to {result['cursor']}; nothing pushed — "
3842
+ "commits from here on will sync."
3843
+ )
3844
+ return 0
3845
+
3846
+ url = os.environ.get("DEVTORCH_MCP_URL", "")
3847
+ api_key = os.environ.get("DEVTORCH_API_KEY", "")
3848
+ if not url or not api_key:
3849
+ try:
3850
+ mcp = json.loads((root / ".mcp.json").read_text(encoding="utf-8"))
3851
+ env_cfg = mcp["mcpServers"]["devtorch"]["env"]
3852
+ url = url or env_cfg.get("DEVTORCH_MCP_URL", "")
3853
+ api_key = api_key or env_cfg.get("DEVTORCH_API_KEY", "")
3854
+ except (OSError, KeyError, TypeError, json.JSONDecodeError):
3855
+ pass
3856
+ if not url or not api_key:
3857
+ print("error: need DEVTORCH_MCP_URL and DEVTORCH_API_KEY (env or .mcp.json)", file=sys.stderr)
3858
+ return 1
3859
+ messages_url = url.rstrip("/").removesuffix("/sse") + "/messages?sessionId=cli-sync"
3860
+ try:
3861
+ result = sync_commits(root / ".GCC", messages_url, api_key)
3862
+ except Exception as exc:
3863
+ print(f"error: cloud sync failed — {exc}", file=sys.stderr)
3864
+ return 1
3865
+ print(f"Pushed {result['pushed']} commit(s); cursor at {result['cursor']}.")
3866
+ return 0
3867
+
3868
+ try:
3869
+ _admin_backend = create_backend_from_env(prefix=args.org)
3870
+ except (KeyError, ValueError) as exc:
3871
+ print(f"error: storage configuration: {exc}", file=sys.stderr)
3872
+ return 1
3873
+
3874
+ _KEYS_PATH = ".devtorch/api_keys.json"
3875
+
3876
+ if args.cloud_command == "key":
3877
+ if args.key_command == "create":
3878
+ raw_key = secrets.token_urlsafe(32)
3879
+ key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
3880
+ try:
3881
+ existing = json.loads(_admin_backend.read_bytes(_KEYS_PATH))
3882
+ except FileNotFoundError:
3883
+ existing = {}
3884
+ existing[key_hash] = {
3885
+ "org_id": args.org,
3886
+ "allowed_repos": [args.repo] if args.repo else None,
3887
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
3888
+ }
3889
+ _admin_backend.write_bytes(_KEYS_PATH, json.dumps(existing, indent=2).encode())
3890
+ print(f"API key created. Store this — it will not be shown again:\n\n {raw_key}\n")
3891
+ if args.github:
3892
+ _set_github_secret(args.github, "DEVTORCH_API_KEY", raw_key)
3893
+ return 0
3894
+
3895
+ elif args.key_command == "list":
3896
+ try:
3897
+ data = json.loads(_admin_backend.read_bytes(_KEYS_PATH))
3898
+ except FileNotFoundError:
3899
+ print("No API keys found.")
3900
+ return 0
3901
+ except json.JSONDecodeError as exc:
3902
+ print(f"error: keys file is corrupted: {exc}", file=sys.stderr)
3903
+ return 1
3904
+ if not data:
3905
+ print("No API keys found.")
3906
+ return 0
3907
+ print(f"{'KEY HASH (first 16)':20} {'ORG':16} {'REPOS':20} {'STATUS':10} CREATED")
3908
+ print("-" * 92)
3909
+ for key_hash, entry in sorted(data.items(), key=lambda x: x[1].get("created_at", "")):
3910
+ repos = ",".join(entry.get("allowed_repos") or []) or "(all)"
3911
+ created = entry.get("created_at", "unknown")[:19]
3912
+ status = "revoked" if entry.get("revoked_at") else "active"
3913
+ print(f"{key_hash[:16]:20} {entry.get('org_id',''):16} {repos:20} {status:10} {created}")
3914
+ return 0
3915
+
3916
+ elif args.key_command == "revoke":
3917
+ try:
3918
+ data = json.loads(_admin_backend.read_bytes(_KEYS_PATH))
3919
+ except FileNotFoundError:
3920
+ print("No API keys found.")
3921
+ return 1
3922
+ except json.JSONDecodeError as exc:
3923
+ print(f"error: keys file is corrupted: {exc}", file=sys.stderr)
3924
+ return 1
3925
+ if len(args.key_prefix) < 8:
3926
+ print("error: key_prefix must be at least 8 characters to avoid accidental matches.", file=sys.stderr)
3927
+ return 1
3928
+ prefix_to_revoke = args.key_prefix.lower()
3929
+ matches = [h for h in data if h.startswith(prefix_to_revoke)]
3930
+ if not matches:
3931
+ print(f"No key found with prefix: {prefix_to_revoke}")
3932
+ return 1
3933
+ if len(matches) > 1:
3934
+ print(f"Ambiguous prefix matches {len(matches)} keys. Use more characters.")
3935
+ return 1
3936
+ if not args.yes:
3937
+ confirm = input(f"Revoke key {matches[0][:16]}...? This cannot be undone. [y/N] ").strip().lower()
3938
+ if confirm != "y":
3939
+ print("Aborted.")
3940
+ return 0
3941
+ old_entry = data[matches[0]]
3942
+ old_entry["revoked_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
3943
+ data[matches[0]] = old_entry
3944
+ _admin_backend.write_bytes(_KEYS_PATH, json.dumps(data, indent=2).encode())
3945
+ print(f"Revoked key {matches[0][:32]}...")
3946
+ return 0
3947
+
3948
+ elif args.key_command == "rotate":
3949
+ try:
3950
+ data = json.loads(_admin_backend.read_bytes(_KEYS_PATH))
3951
+ except FileNotFoundError:
3952
+ print("No API keys found.")
3953
+ return 1
3954
+ except json.JSONDecodeError as exc:
3955
+ print(f"error: keys file is corrupted: {exc}", file=sys.stderr)
3956
+ return 1
3957
+ if len(args.key_prefix) < 8:
3958
+ print("error: key_prefix must be at least 8 characters to avoid accidental matches.", file=sys.stderr)
3959
+ return 1
3960
+ prefix_to_rotate = args.key_prefix.lower()
3961
+ matches = [h for h in data if h.startswith(prefix_to_rotate)]
3962
+ if not matches:
3963
+ print(f"No key found with prefix: {prefix_to_rotate}")
3964
+ return 1
3965
+ if len(matches) > 1:
3966
+ print(f"Ambiguous prefix matches {len(matches)} keys. Use more characters.")
3967
+ return 1
3968
+ if not args.yes:
3969
+ confirm = input(f"Rotate key {matches[0][:16]}...? This will revoke the old key and create a new one. [y/N] ").strip().lower()
3970
+ if confirm != "y":
3971
+ print("Aborted.")
3972
+ return 0
3973
+ old_hash = matches[0]
3974
+ old_entry = data[old_hash]
3975
+ old_entry["revoked_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
3976
+ old_entry["rotated_from"] = old_hash
3977
+ data[old_hash] = old_entry
3978
+
3979
+ raw_key = secrets.token_urlsafe(32)
3980
+ new_hash = hashlib.sha256(raw_key.encode()).hexdigest()
3981
+ data[new_hash] = {
3982
+ "org_id": args.org,
3983
+ "allowed_repos": old_entry.get("allowed_repos"),
3984
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
3985
+ "rotated_from": old_hash,
3986
+ }
3987
+ _admin_backend.write_bytes(_KEYS_PATH, json.dumps(data, indent=2).encode())
3988
+ print(f"Rotated key {old_hash[:32]}... → {new_hash[:32]}...")
3989
+ print(f"New API key (store safely — it will not be shown again):\n\n {raw_key}\n")
3990
+ return 0
3991
+
3992
+ if args.cloud_command == "repo":
3993
+ import os
3994
+ encryption_key_hex = os.environ.get("DEVTORCH_PAT_ENCRYPTION_KEY", "")
3995
+ if not encryption_key_hex:
3996
+ print("error: DEVTORCH_PAT_ENCRYPTION_KEY is required for repo operations.", file=sys.stderr)
3997
+ return 1
3998
+ try:
3999
+ encryption_key = bytes.fromhex(encryption_key_hex)
4000
+ except ValueError:
4001
+ print("error: DEVTORCH_PAT_ENCRYPTION_KEY must be a hex string.", file=sys.stderr)
4002
+ return 1
4003
+
4004
+ from devtorch_core.github.pat import GitHubPATStore
4005
+ pat_store = GitHubPATStore(backend=_admin_backend, encryption_key=encryption_key)
4006
+
4007
+ if args.repo_command == "connect":
4008
+ if args.pat:
4009
+ pat_store.store_pat(repo_id=args.repo, pat=args.pat, github_repo=args.github)
4010
+ print(f"Connected: {args.org}/{args.repo} → github.com/{args.github} (PAT)")
4011
+ elif args.github_app:
4012
+ print("GitHub App connection is managed automatically via webhook.")
4013
+ print("Install the DevTorch GitHub App on your repo — the server handles registration.")
4014
+ return 0
4015
+
4016
+ elif args.repo_command == "list":
4017
+ repos = pat_store.list_repos()
4018
+ if not repos:
4019
+ print("No repositories connected.")
4020
+ else:
4021
+ for r in repos:
4022
+ entry = pat_store.get_pat(r)
4023
+ github_repo = entry["github_repo"] if entry else "(PAT decryption failed)"
4024
+ print(f" {r} → github.com/{github_repo}")
4025
+ return 0
4026
+
4027
+ elif args.repo_command == "disconnect":
4028
+ pat_store.delete_pat(repo_id=args.repo)
4029
+ print(f"Disconnected: {args.repo}")
4030
+ return 0
4031
+
4032
+ print(f"error: unknown cloud command: {args.cloud_command}", file=sys.stderr)
4033
+ return 1
4034
+
4035
+ # S11: hooks
4036
+ if args.command == "hooks":
4037
+ from devtorch_core.hooks.installer import install_all_hooks, get_hooks_status
4038
+
4039
+ # Try to resolve the project root from a GCC repo, fall back to cwd
4040
+ try:
4041
+ gcc_repo = GCCRepository.at(Path(args.path).resolve())
4042
+ project_root = str(gcc_repo.root) if gcc_repo.is_initialized() else args.path
4043
+ except Exception:
4044
+ project_root = args.path
4045
+
4046
+ if args.hooks_command == "install":
4047
+ result = install_all_hooks(project_root)
4048
+ print(f"Claude hooks: {'✓' if result.claude_hooks_written else '✗'} {result.claude_hooks_path}")
4049
+ print(f"Git hook: {'✓' if result.git_hook_written else '✗'} {result.git_hook_path}")
4050
+ if getattr(result, "git_capture_hooks_written", False):
4051
+ print(f"Git capture hooks: ✓ ({len(result.git_capture_hooks)} installed)")
4052
+ else:
4053
+ print(f"Git capture hooks: ✗")
4054
+ if result.errors:
4055
+ for e in result.errors:
4056
+ print(f" Error: {e}")
4057
+ elif args.hooks_command == "status":
4058
+ status = get_hooks_status(project_root)
4059
+ for k, v in status.items():
4060
+ print(f"{k}: {v}")
4061
+ elif args.hooks_command == "git-commit":
4062
+ from devtorch_core.hooks.git_commit import handle_commit_msg
4063
+ handle_commit_msg(args.commit_msg_file)
4064
+ elif args.hooks_command == "git-capture":
4065
+ from devtorch_core.hooks.git_capture import handle_git_capture
4066
+ handle_git_capture(args.event, args.args)
4067
+ elif args.hooks_command == "run":
4068
+ from devtorch_core.hooks.runner import run as run_hook
4069
+ run_hook(args.hook_name)
4070
+ return 0
4071
+
4072
+ if args.command == "reasoning":
4073
+ if getattr(args, "reasoning_cmd", None) is None:
4074
+ # Print help when no subcommand given
4075
+ build_parser().parse_args(["reasoning", "--help"])
4076
+ return 0
4077
+ return args.func(args)
4078
+
4079
+ if args.command == "reasoning-plus":
4080
+ if getattr(args, "reasoning_plus_cmd", None) is None:
4081
+ build_parser().parse_args(["reasoning-plus", "--help"])
4082
+ return 0
4083
+ return args.func(args)
4084
+
4085
+ return args.func(args)
4086
+
4087
+
4088
+ if __name__ == "__main__":
4089
+ raise SystemExit(main())
4090
+