-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
1146 lines (939 loc) · 37.8 KB
/
Copy path__init__.py
File metadata and controls
1146 lines (939 loc) · 37.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""CLI module for social-hook."""
from pathlib import Path
import typer
from social_hook import __version__
from social_hook.constants import PROJECT_DESCRIPTION, PROJECT_NAME, PROJECT_SLUG
def _init_logging(
component: str, *, console: bool = True, notify: bool = False, run_id: bool = False
) -> None:
"""Initialize unified logging pipeline for a CLI entry point.
Args:
component: Component name (e.g., "trigger", "scheduler", "bot")
console: Whether to show ERROR+ on stderr
notify: Whether to send ERROR/CRITICAL to notification channels
run_id: Whether to generate and set a correlation run_id
"""
import sys
from social_hook.error_feed import error_feed
from social_hook.filesystem import get_db_path
from social_hook.logging import setup_logging
try:
from social_hook.config import load_full_config
config = load_full_config()
error_feed.set_db_path(str(get_db_path()))
except Exception as e:
print(
f"Logging init: config not available ({e}), DB/notification sinks disabled",
file=sys.stderr,
)
config = None
sender = None
if notify and config:
from social_hook.notifications import send_notification
# NotificationSink already formats as "[SEVERITY] (source) message"
# so the sender just passes through — no extra wrapping needed.
def sender(_sev, msg):
send_notification(config, msg)
setup_logging(
component,
error_feed=error_feed if config else None,
notification_sender=sender,
console=console,
)
# Wire on_persist so errors from this process trigger WebSocket updates
# in the web dashboard (cross-process via web_events table).
if config:
import sqlite3
import threading
_persist_sem = threading.Semaphore(10)
db_path_str = str(get_db_path())
def _on_error_persisted(error_id, severity, comp):
if not _persist_sem.acquire(blocking=False):
return
def _emit():
try:
from social_hook.db import operations as ops
conn = sqlite3.connect(db_path_str, timeout=2)
conn.row_factory = sqlite3.Row
try:
ops.emit_data_event(
conn,
"system_error",
"created",
error_id,
extra={"severity": severity, "component": comp},
)
finally:
conn.close()
except Exception:
pass
finally:
_persist_sem.release()
threading.Thread(target=_emit, daemon=True).start()
error_feed.set_on_persist(_on_error_persisted)
if run_id:
from social_hook.filesystem import generate_id
from social_hook.logging import set_run_id
set_run_id(generate_id("run"))
# Create main Typer app
app = typer.Typer(
name=PROJECT_SLUG,
help=f"{PROJECT_DESCRIPTION}.",
no_args_is_help=True,
)
# Global options callback
@app.callback()
def main(
ctx: typer.Context,
config: Path | None = typer.Option(
None,
"--config",
"-c",
help="Override config location",
envvar="SOCIAL_HOOK_CONFIG",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Run full pipeline without posting or DB writes (for testing)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Verbose output",
),
json_output: bool = typer.Option(
False,
"--json",
help="JSON output for scripting",
),
):
"""Social Hook - Automated social media content from development activity."""
# Store options in context for subcommands
ctx.ensure_object(dict)
ctx.obj["config"] = config
ctx.obj["dry_run"] = dry_run
ctx.obj["verbose"] = verbose
ctx.obj["json"] = json_output
@app.command()
def version():
"""Show version information."""
typer.echo(f"{PROJECT_SLUG} {__version__}")
@app.command("help", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
def help_cmd(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Output as structured JSON"),
):
"""Show command help. Use --json for machine-readable output.
Examples: social-hook help draft, social-hook help draft approve, social-hook help --json
"""
import json as json_mod
import click
click_app = typer.main.get_command(app)
# Handle --json appearing after command path (forgiving flag placement)
if "--json" in ctx.args:
json_output = True
command_parts = [a for a in ctx.args if a != "--json"]
else:
command_parts = ctx.args # e.g. ["draft", "approve"]
def _cmd_to_dict(cmd, name=None):
result = {}
if name:
result["name"] = name
if cmd.help:
result["help"] = cmd.help.split("\n")[0]
result["description"] = cmd.help.strip()
if hasattr(cmd, "commands") and cmd.commands:
cmds = {}
for sub_name in sorted(cmd.commands):
sub_cmd = cmd.commands[sub_name]
if getattr(sub_cmd, "hidden", False):
continue
cmds[sub_name] = _cmd_to_dict(sub_cmd, sub_name)
if cmds:
result["commands"] = cmds
args = []
for param in cmd.params:
if isinstance(param, click.Argument):
args.append(
{
"name": param.name,
"required": param.required,
}
)
if args:
result["arguments"] = args
opts = []
skip_names = {"install_completion", "show_completion", "help", "ctx"}
for param in cmd.params:
if isinstance(param, click.Option):
if param.name in skip_names:
continue
opt_info = {
"name": param.opts[0] if param.opts else f"--{param.name}",
}
if len(param.opts) > 1:
opt_info["short"] = param.opts[1]
if param.help:
opt_info["help"] = param.help
type_name = param.type.name if hasattr(param.type, "name") else str(param.type)
opt_info["type"] = type_name.upper()
if param.default is not None:
opt_info["default"] = param.default
opts.append(opt_info)
if opts:
result["options"] = opts
return result
def _resolve_command(parts):
"""Walk the Click command tree following the given path parts."""
from difflib import get_close_matches
current = click_app
info_parts = [PROJECT_SLUG]
for part in parts:
if not hasattr(current, "commands") or not current.commands:
typer.echo(f"Unknown command: {' '.join(parts)}")
raise typer.Exit(1)
sub = current.commands.get(part)
if not sub:
available = sorted(current.commands.keys())
suggestions = get_close_matches(part, available, n=3, cutoff=0.5)
msg = f"Unknown command: {part}"
if suggestions:
msg += "\n\nDid you mean?"
for s in suggestions:
msg += f"\n {' '.join(info_parts)} {s}"
else:
msg += f"\n\nAvailable: {', '.join(available)}"
typer.echo(msg)
raise typer.Exit(1)
current = sub
info_parts.append(part)
return current, " ".join(info_parts)
if json_output:
if command_parts:
target, _ = _resolve_command(command_parts)
typer.echo(
json_mod.dumps(_cmd_to_dict(target, command_parts[-1]), indent=2, default=str)
)
else:
global_options = []
skip_names = {"install_completion", "show_completion", "help", "ctx"}
for param in click_app.params:
if isinstance(param, click.Option) and param.name not in skip_names:
opt_info = {
"name": param.opts[0] if param.opts else f"--{param.name}",
}
if len(param.opts) > 1:
opt_info["short"] = param.opts[1]
if param.help:
opt_info["help"] = param.help
type_name = param.type.name if hasattr(param.type, "name") else str(param.type)
opt_info["type"] = type_name.upper()
if param.default is not None:
opt_info["default"] = param.default # type: ignore[assignment]
global_options.append(opt_info)
output = {
"name": PROJECT_SLUG,
"global_options": global_options,
"commands": {},
}
for cmd_name in sorted(click_app.commands): # type: ignore[attr-defined]
cmd = click_app.commands[cmd_name] # type: ignore[attr-defined]
if getattr(cmd, "hidden", False):
continue
output["commands"][cmd_name] = _cmd_to_dict(cmd, cmd_name) # type: ignore[index]
typer.echo(json_mod.dumps(output, indent=2, default=str))
elif command_parts:
try:
target, info_name = _resolve_command(command_parts)
help_ctx = click.Context(target, info_name=info_name)
typer.echo(target.get_help(help_ctx))
except typer.Exit:
raise
except Exception as e:
typer.echo(f"Error: {e}")
raise typer.Exit(1) from None
else:
help_ctx = click.Context(click_app, info_name=PROJECT_SLUG)
typer.echo(click_app.get_help(help_ctx))
@app.command()
def init():
"""Initialize social-hook (create directories and database).
Creates ~/.social-hook/ with config templates and an empty database.
For guided setup with platform credentials, use 'social-hook setup' instead.
"""
from social_hook.db import init_database
from social_hook.filesystem import get_db_path, init_filesystem
# Initialize file system
base = init_filesystem()
typer.echo(f"Created directory structure at {base}")
# Initialize database
db_path = get_db_path()
init_database(db_path)
typer.echo(f"Initialized database at {db_path}")
typer.echo("\nNext steps:")
typer.echo(f" 1. Copy {base}/.env.example to {base}/.env and add your API keys")
typer.echo(f" 2. Copy {base}/config.yaml.example to {base}/config.yaml and customize")
typer.echo(f" 3. Run: {PROJECT_SLUG} register /path/to/your/repo")
@app.command()
def trigger(
ctx: typer.Context,
commit: str = typer.Option(..., "--commit", help="Commit hash to evaluate"),
repo: str = typer.Option(..., "--repo", help="Repository path"),
):
"""Run the full evaluation-to-draft pipeline for a single commit.
Evaluates the commit with the LLM, records a decision, and creates
drafts for each enabled platform if the commit is post-worthy.
This is the same pipeline the git post-commit hook runs automatically.
Use 'social-hook test' for dry-run evaluation without database writes.
"""
from social_hook.trigger import run_trigger
_init_logging("trigger", notify=True, run_id=True)
dry_run = ctx.obj.get("dry_run", False)
verbose = ctx.obj.get("verbose", False)
config_path = ctx.obj.get("config")
from social_hook.cli._spinner import spinner
with spinner("Evaluating commit..."):
exit_code = run_trigger(
commit_hash=commit,
repo_path=repo,
dry_run=dry_run,
config_path=str(config_path) if config_path else None,
verbose=verbose,
)
raise SystemExit(exit_code)
@app.command("scheduler-tick")
def scheduler_tick(
ctx: typer.Context,
):
"""Post scheduled drafts whose time has arrived and promote deferred drafts.
Checks for drafts with status 'scheduled' past their scheduled time and
posts them to their platform. Also promotes deferred drafts when scheduling
slots open up, and drains rate-limited evaluations.
Typically run on a cron (e.g. every minute) or by the bot daemon.
Example: social-hook scheduler-tick
Example: social-hook --dry-run scheduler-tick
"""
from social_hook.scheduler import scheduler_tick as do_tick
_init_logging("scheduler", notify=True, run_id=True)
dry_run = ctx.obj.get("dry_run", False)
config_path = ctx.obj.get("config")
processed = do_tick(
dry_run=dry_run,
config_path=str(config_path) if config_path else None,
)
if processed > 0:
typer.echo(f"Processed {processed} draft(s)")
@app.command("consolidation-tick")
def consolidation_tick_cmd(
ctx: typer.Context,
):
"""Process held decisions — commits not post-worthy alone but interesting together.
When the evaluator marks a commit as 'hold', it means the commit isn't worth
a standalone post but could be combined with others. This command batches
those held decisions and either sends a summary notification (notify_only mode)
or re-evaluates the batch as a group (re_evaluate mode).
Typically run on a cron (e.g. every few hours) or by the bot daemon.
Example: social-hook consolidation-tick
"""
from social_hook.consolidation import consolidation_tick as do_tick
_init_logging("consolidation", notify=True, run_id=True)
dry_run = ctx.obj.get("dry_run", False)
config_path = ctx.obj.get("config")
processed = do_tick(
dry_run=dry_run,
config_path=str(config_path) if config_path else None,
)
if processed > 0:
typer.echo(f"Processed {processed} consolidation decision(s)")
@app.command()
def web(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", "-p", help="Port for Next.js dev server"),
api_port: int = typer.Option(8741, "--api-port", help="Port for FastAPI server"),
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
install: bool = typer.Option(False, "--install", help="Run npm install before starting"),
):
"""Start the web dashboard for managing your social-hook workflow visually.
Launches a Next.js frontend and FastAPI backend. From the dashboard you can
review and edit drafts, approve or reject posts, manage projects, configure
settings, monitor the pipeline in real time, and more.
Requires Node.js. Use --install to run npm install on first launch.
Example: social-hook web
Example: social-hook web --port 8080 --install
"""
import shutil
import subprocess as sp
if not shutil.which("node"):
typer.echo("Error: Node.js is required for the web dashboard but was not found.")
typer.echo("Install Node.js from https://nodejs.org/")
raise typer.Exit(1)
web_dir = Path(__file__).resolve().parent.parent.parent.parent / "web"
if not web_dir.exists():
typer.echo(f"Error: Web directory not found at {web_dir}")
typer.echo("The web dashboard may not be installed.")
raise typer.Exit(1)
if install or not (web_dir / "node_modules").exists():
typer.echo("Running npm install...")
result = sp.run(["npm", "install"], cwd=str(web_dir))
if result.returncode != 0:
typer.echo("Error: npm install failed")
raise typer.Exit(1)
import socket
def _find_free_port(start: int, bind_host: str) -> int:
for p in range(start, start + 10):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind((bind_host, p))
return p
except OSError:
continue
return start
# Kill any stale process on the API port before starting
def _kill_port(p: int) -> None:
try:
out = sp.check_output(["lsof", "-ti", f":{p}"], text=True).strip()
if out:
for pid in out.split("\n"):
sp.run(["kill", "-9", pid.strip()], check=False)
import time
time.sleep(0.5)
except (sp.CalledProcessError, FileNotFoundError):
pass
_kill_port(api_port)
port = _find_free_port(port, host)
api_port = _find_free_port(api_port, host)
# Start FastAPI in background
typer.echo(f"Starting API server on {host}:{api_port}...")
api_proc = sp.Popen(
["uvicorn", "social_hook.web.server:app", "--host", host, "--port", str(api_port)],
)
try:
# Start Next.js in foreground
typer.echo(f"Starting web dashboard on http://{host}:{port}...")
import os
next_env = os.environ.copy()
next_env["NEXT_PUBLIC_API_URL"] = f"http://{host}:{api_port}"
next_env["NEXT_PUBLIC_API_PORT"] = str(api_port)
next_env["NEXT_PUBLIC_PROJECT_NAME"] = PROJECT_NAME
next_env["NEXT_PUBLIC_PROJECT_SLUG"] = PROJECT_SLUG
sp.run(
["npx", "next", "dev", "--port", str(port)],
cwd=str(web_dir),
env=next_env,
)
except KeyboardInterrupt:
typer.echo("\nShutting down...")
finally:
api_proc.terminate()
try:
api_proc.wait(timeout=3)
except sp.TimeoutExpired:
api_proc.kill()
api_proc.wait(timeout=2)
# =============================================================================
# Bot subcommand group
# =============================================================================
bot_app = typer.Typer(
name="bot",
help="Control the background bot daemon that continuously runs scheduler-tick and consolidation-tick to automate posting.",
no_args_is_help=True,
)
app.add_typer(bot_app, name="bot")
@bot_app.command("start")
def bot_start(
ctx: typer.Context,
daemon: bool = typer.Option(False, "--daemon", "-d", help="Run as background daemon"),
):
"""Start the bot daemon."""
import os
from social_hook.bot.process import is_running, read_pid
# If the PID file contains our own PID, we were spawned by the
# parent daemon launcher (eager PID write) — proceed normally.
if is_running() and read_pid() != os.getpid():
typer.echo("Bot is already running.")
raise typer.Exit(1)
from social_hook.config import load_full_config
config_path = ctx.obj.get("config") if ctx.obj else None
config = load_full_config(str(config_path) if config_path else None)
from social_hook.bot.daemon import create_bot
from social_hook.bot.process import get_pid_file
from social_hook.errors import ConfigError
try:
bot = create_bot(config=config)
except ConfigError as e:
typer.echo(f"Error: {e}")
raise typer.Exit(1) from None
if daemon:
import shutil
import subprocess as sp
import sys
from social_hook.filesystem import get_base_path
log_path = get_base_path() / "logs" / "bot.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fd = open(log_path, "a") # noqa: SIM115 — fd must outlive this scope for subprocess
# Re-invoke in foreground mode as a detached subprocess
binary = shutil.which(PROJECT_SLUG) or PROJECT_SLUG
cmd = [binary, "bot", "start"]
if config_path:
cmd.extend(["--config", str(config_path)])
kwargs: dict = {"stdout": log_fd, "stderr": log_fd, "stdin": sp.DEVNULL}
if sys.platform == "win32":
kwargs["creationflags"] = sp.DETACHED_PROCESS | sp.CREATE_NEW_PROCESS_GROUP
else:
kwargs["start_new_session"] = True
proc = sp.Popen(cmd, **kwargs)
log_fd.close() # Child inherited the FD; parent doesn't need it
# Write PID eagerly so is_running() returns true immediately,
# preventing duplicate daemons from concurrent start requests.
# The child will overwrite with the same PID in bot.run().
pid_file = get_pid_file()
pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(str(proc.pid))
typer.echo(f"Bot started (PID {proc.pid})")
return
else:
_init_logging("bot", notify=True)
typer.echo("Bot starting (foreground mode, Ctrl+C to stop)...")
bot.run(pid_file=get_pid_file())
@bot_app.command("stop")
def bot_stop():
"""Stop the bot daemon."""
from social_hook.bot.process import is_running, stop_bot
if not is_running():
typer.echo("Bot is not running.")
return
typer.echo("Stopping bot (may take up to 40s)...")
if stop_bot():
typer.echo("Bot stopped.")
else:
typer.echo("Failed to stop bot.")
@bot_app.command("status")
def bot_status():
"""Check if the bot daemon is running."""
from social_hook.bot.process import is_running, read_pid
if is_running():
pid = read_pid()
typer.echo(f"Bot is running (PID {pid})")
else:
typer.echo("Bot is not running.")
@app.command()
def discover(
ctx: typer.Context,
project_id: str = typer.Argument(..., help="Project ID to discover"),
):
"""Analyse your repo with LLM-powered two-pass discovery.
Pass 1: the AI selects the most important files from your repo listing.
Pass 2: reads those files and generates a project summary, per-file
summaries, and identifies key documentation. This context is used by
the evaluator and drafter in all future pipeline runs.
Usually run automatically by quickstart, but can be re-run to refresh
the project summary after significant changes.
Example: social-hook discover my-project-id
"""
from social_hook.config.yaml import load_full_config
from social_hook.db import operations as ops
from social_hook.db.connection import init_database
from social_hook.filesystem import get_db_path
_init_logging("cli")
verbose = ctx.obj.get("verbose", False)
config_path = ctx.obj.get("config")
try:
config = load_full_config(
yaml_path=str(config_path) if config_path else None,
)
except Exception as e:
typer.echo(f"Config error: {e}", err=True)
raise typer.Exit(1) from None
db_path = get_db_path()
conn = init_database(db_path)
project = ops.get_project(conn, project_id)
if project is None:
typer.echo(f"Project not found: {project_id}", err=True)
conn.close()
raise typer.Exit(1)
from social_hook.config.project import load_project_config
from social_hook.llm.discovery import discover_project
from social_hook.llm.dry_run import DryRunContext
from social_hook.llm.factory import create_client
project_config = load_project_config(project.repo_path)
dry_run = ctx.obj.get("dry_run", False)
db_ctx = DryRunContext(conn, dry_run=dry_run)
client = create_client(config.models.evaluator, config, verbose=verbose)
typer.echo(f"Discovering project: {project.name} ({project.repo_path})")
summary, selected_files, file_summaries, prompt_docs = discover_project(
client=client,
repo_path=project.repo_path,
project_docs=project_config.context.project_docs,
max_discovery_tokens=project_config.context.max_discovery_tokens,
max_file_size=project_config.context.max_file_size,
db=db_ctx,
project_id=project.id,
on_progress=lambda stage: (
typer.echo(f"[{stage}] {project.name}"), # type: ignore[func-returns-value]
ops.emit_data_event(conn, "pipeline", stage, project.id, project.id), # type: ignore[func-returns-value]
),
)
if summary:
if not dry_run:
ops.update_project_summary(conn, project.id, summary)
ops.update_discovery_files(conn, project.id, selected_files)
if file_summaries:
ops.upsert_file_summaries(conn, project.id, file_summaries)
if prompt_docs:
ops.update_prompt_docs(conn, project.id, prompt_docs)
typer.echo(f"\nSelected files ({len(selected_files)}):")
for f in selected_files:
typer.echo(f" {f}")
typer.echo(f"\nSummary:\n{summary}")
else:
typer.echo("Discovery failed - no summary generated.", err=True)
conn.close()
raise typer.Exit(1)
conn.close()
# =============================================================================
# Hidden commands (called by hooks, not by users)
# =============================================================================
@app.command("commit-hook", hidden=True)
def commit_hook():
"""Internal: called by PostToolUse hook. Reads JSON from stdin, filters for git commits."""
import json
import re
import sys
_init_logging("trigger", console=False, notify=True)
try:
data = json.loads(sys.stdin.read())
except (json.JSONDecodeError, EOFError):
return # Silently exit — not our concern
command = data.get("tool_input", {}).get("command", "")
if not re.search(r"git\s+(commit|merge|rebase|cherry-pick)", command):
return # Not a git commit command, nothing to do
cwd = data.get("cwd", "")
if not cwd:
return
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=cwd,
)
if result.returncode != 0:
return
commit_hash = result.stdout.strip()
except Exception:
return
from social_hook.trigger import run_trigger
run_trigger(commit_hash=commit_hash, repo_path=cwd)
@app.command("git-hook", hidden=True)
def git_hook():
"""Internal: called by git post-commit hook. Detects commit and triggers pipeline."""
import logging
import subprocess
_init_logging("trigger", console=False, notify=True)
logger = logging.getLogger("social_hook.git_hook")
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to get repo root: %s", result.stderr)
return
repo_path = result.stdout.strip()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=repo_path,
)
if result.returncode != 0:
logger.error("Failed to get HEAD: %s", result.stderr)
return
commit_hash = result.stdout.strip()
logger.info("Git hook triggered: %s in %s", commit_hash[:8], repo_path)
from social_hook.trigger import run_trigger
exit_code = run_trigger(commit_hash=commit_hash, repo_path=repo_path)
logger.info("Trigger completed with exit code %d", exit_code)
except Exception:
logger.exception("Git hook failed")
@app.command("narrative-capture", hidden=True)
def narrative_capture():
"""Internal: called by PreCompact hook. Reads JSON from stdin."""
import json
import logging
import os
import sys
_init_logging("narrative", console=False)
logger = logging.getLogger("social_hook.narrative_capture")
try:
data = json.loads(sys.stdin.read())
session_id = data.get("session_id", "")
transcript_path = data.get("transcript_path", "")
cwd = data.get("cwd", "")
trigger = data.get("trigger", "unknown")
# Load config, check enabled
from social_hook.config.yaml import load_full_config
config = load_full_config()
if not config.journey_capture.enabled:
return
# Init DB
from social_hook.db.connection import init_database
from social_hook.filesystem import get_db_path
db_path = get_db_path()
conn = init_database(db_path)
# Normalize cwd and look up project (matches trigger.py pattern)
normalized_cwd = os.path.realpath(cwd).rstrip("/")
from social_hook.db import operations as ops
project = ops.get_project_by_path(conn, normalized_cwd)
if project is None:
from social_hook.trigger import git_remote_origin
origin = git_remote_origin(cwd)
if origin:
projects = ops.get_project_by_origin(conn, origin)
if projects:
project = projects[0]
if project is None:
logger.debug("No registered project for cwd=%s", cwd)
conn.close()
return
if project.paused:
logger.debug("Project %s is paused, skipping", project.id)
conn.close()
return
# Resolve model, reject Haiku
model_str = config.journey_capture.model or config.models.evaluator
if "haiku" in model_str.lower():
logger.warning(
"Skipping narrative extraction: %s is too small. Use Sonnet or Opus.",
model_str,
)
conn.close()
return
# Resolve transcript path (with fallback for empty path bug)
from pathlib import Path
from social_hook.narrative.transcript import (
discover_transcript_path,
filter_for_extraction,
format_for_prompt,
read_transcript,
truncate_to_budget,
)
resolved_path = transcript_path
if not resolved_path or not Path(resolved_path).exists():
resolved_path = discover_transcript_path(session_id, cwd)
if not resolved_path or not Path(resolved_path).exists():
logger.debug(
"Transcript not found for session=%s cwd=%s",
session_id,
cwd,
)
conn.close()
return
# Read -> filter -> format -> truncate
messages = read_transcript(resolved_path)
filtered = filter_for_extraction(messages)
if not filtered:
logger.debug("No conversational content in transcript")
conn.close()
return
formatted = format_for_prompt(filtered)
text = truncate_to_budget(formatted)
# Extract narrative
from social_hook.llm.dry_run import DryRunContext
from social_hook.llm.factory import create_client
from social_hook.narrative.extractor import NarrativeExtractor
db_ctx = DryRunContext(conn, dry_run=False)
client = create_client(model_str, config)
extractor = NarrativeExtractor(client)
extraction = extractor.extract(
transcript_text=text,
project_name=project.name,
cwd=normalized_cwd,
db=db_ctx,
project_id=project.id,
)
if extraction is None:
conn.close()
return
# Save narrative
from social_hook.narrative.storage import (
cleanup_old_narratives,
save_narrative,
)
save_narrative(project.id, extraction, session_id, trigger)
cleanup_old_narratives(project.id)
logger.info(
"Narrative captured for project=%s session=%s",
project.id,
session_id,
)
conn.close()
except Exception:
logging.getLogger("social_hook.narrative_capture").exception("narrative-capture failed")
# Exit 0 -- never disrupt the user's session
# =============================================================================
# Register subcommand modules
# =============================================================================
from social_hook.cli.arc import app as arc_app
from social_hook.cli.config import app as config_app
from social_hook.cli.inspect import app as inspect_app
from social_hook.cli.journey import app as journey_app
from social_hook.cli.manual import app as manual_app
from social_hook.cli.memory import app as memory_app
from social_hook.cli.project import app as project_app
from social_hook.cli.setup import app as setup_app
from social_hook.cli.test_cmd import app as test_app
# Project commands: register, unregister, list
app.add_typer(
project_app,
name="project",
help="Register and manage projects. A project links a git repository (or folder) to Social Hook so commits are evaluated, content is drafted, and briefs are maintained.",
)
# Inspection commands: log, pending, usage
app.add_typer(
inspect_app,
name="inspect",
help="Query system state: view the decision log, list pending drafts, check LLM token usage and costs, and see platform configuration status.",
)
# Manual commands: evaluate, draft, post
app.add_typer(
manual_app,
name="manual",
help="Manually trigger individual pipeline steps outside automation: evaluate commits, create drafts, consolidate multi-commit posts, or post immediately.",
)
# Setup wizard
app.add_typer(setup_app, name="setup", help=f"Configure {PROJECT_SLUG}.")
# Test command
app.add_typer(test_app, name="test", help="Test commit evaluation.")
# Journey capture commands: on, off, status
app.add_typer(
journey_app,
name="journey",
help="Control Development Journey capture. When enabled, Claude Code hooks record session narratives that feed into the evaluation pipeline as rich development context.",