pathscout 0.3.0__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.
pathscout/cli.py ADDED
@@ -0,0 +1,841 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import warnings
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ from .artifacts import build_artifact, write_json_artifact, write_markdown_artifact, write_package_from_artifact
11
+ from .config import (
12
+ DEFAULT_BACKGROUND,
13
+ DEFAULT_PORTFOLIO,
14
+ DEFAULT_PROFILE,
15
+ DEFAULT_SOURCES,
16
+ DEFAULT_SUPPRESSIONS,
17
+ DEFAULT_WATCHLIST,
18
+ apply_onboarding_answers,
19
+ build_runtime_config,
20
+ default_background,
21
+ ensure_default_files,
22
+ load_background,
23
+ load_profile,
24
+ )
25
+ from .db import connect, init_db
26
+ from .doctor import format_doctor_report, validate_setup
27
+ from .runner import run_sources
28
+ from .watchlist import load_watchlist, summarize_watchlist
29
+ from .workflow import (
30
+ DEFAULT_NOTES,
31
+ DEFAULT_THESES_DIR,
32
+ add_note,
33
+ find_finding,
34
+ load_artifact,
35
+ load_notes,
36
+ related_notes,
37
+ render_explanation,
38
+ render_notes,
39
+ write_thesis,
40
+ )
41
+
42
+
43
+ DEFAULT_DB = Path("data/pathscout.sqlite")
44
+ DEFAULT_JSON_OUT = Path("outputs/latest.json")
45
+ DEFAULT_MD_OUT = Path("outputs/latest.md")
46
+ DEFAULT_PACKAGE_OUT = Path("outputs/packages")
47
+ DEFAULT_BACKGROUND_SAMPLE = Path("config/background.sample.json")
48
+
49
+
50
+ def build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(description="PathScout role discovery radar.")
52
+ subparsers = parser.add_subparsers(dest="command")
53
+
54
+ start_parser = subparsers.add_parser("start", help="Show the first-run startup checklist.")
55
+ add_startup_paths(start_parser)
56
+
57
+ next_parser = subparsers.add_parser("next", aliases=["/next"], help="Show the next recommended PathScout action.")
58
+ add_startup_paths(next_parser)
59
+
60
+ setup_parser = subparsers.add_parser("setup", help="Run the guided local setup flow.")
61
+ add_config_paths(setup_parser)
62
+ setup_parser.add_argument("--background", default=str(DEFAULT_BACKGROUND), help="Path to private background JSON.")
63
+
64
+ init_parser = subparsers.add_parser("init", help="Create sample config and local folders.")
65
+ add_config_paths(init_parser)
66
+ init_parser.add_argument("--environment", help="Answer for: What is the right environment for you?")
67
+ init_parser.add_argument("--role", help="Answer for: What is the right role for you?")
68
+ init_parser.add_argument("--no-input", action="store_true", help="Create defaults without interactive onboarding prompts.")
69
+ init_parser.add_argument("--background", default=str(DEFAULT_BACKGROUND), help="Path to private background JSON.")
70
+
71
+ run_parser = subparsers.add_parser("run", help="Fetch sources, score observations, and write artifacts.")
72
+ add_config_paths(run_parser)
73
+ run_parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to SQLite DB.")
74
+ run_parser.add_argument("--out", default=str(DEFAULT_MD_OUT), help="Path to Markdown output.")
75
+ run_parser.add_argument("--json-out", default=str(DEFAULT_JSON_OUT), help="Path to JSON artifact output.")
76
+ run_parser.add_argument("--format", choices=["markdown", "json", "both"], default="both", help="Artifact format to write.")
77
+ run_parser.add_argument("--digest-window-days", type=int, default=7, help="Observations to include.")
78
+ run_parser.add_argument("--dry-run", action="store_true", help="Fetch and score without writing DB rows.")
79
+
80
+ watchlist_parser = subparsers.add_parser("watchlist", help="Summarize the current company watchlist.")
81
+ watchlist_parser.add_argument("--watchlist", default=str(DEFAULT_WATCHLIST), help="Path to watchlist JSON.")
82
+ watchlist_parser.add_argument("--status", help="Only print companies with this status.")
83
+
84
+ review_parser = subparsers.add_parser("review", help="Review findings from a JSON artifact.")
85
+ review_parser.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON_OUT), help="Path to JSON artifact.")
86
+ review_parser.add_argument("--tier", help="Only show findings in this tier.")
87
+ review_parser.add_argument("--include-suppressed", action="store_true", help="Include suppressed findings.")
88
+ review_parser.add_argument("--limit", type=int, default=20, help="Maximum findings to print.")
89
+
90
+ explain_parser = subparsers.add_parser("explain", help="Explain why a finding surfaced.")
91
+ explain_parser.add_argument("finding_id", help="Finding ID or unique prefix.")
92
+ explain_parser.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON_OUT), help="Path to JSON artifact.")
93
+ explain_parser.add_argument("--notes", default=str(DEFAULT_NOTES), help="Path to notes JSON.")
94
+
95
+ notes_parser = subparsers.add_parser("notes", help="Add or list local notes for a finding or company.")
96
+ notes_parser.add_argument("finding_id", nargs="?", help="Finding ID or unique prefix.")
97
+ notes_parser.add_argument("--company", help="Company name for company-level notes.")
98
+ notes_parser.add_argument("--add", help="Note body to append.")
99
+ notes_parser.add_argument("--notes", default=str(DEFAULT_NOTES), help="Path to notes JSON.")
100
+
101
+ thesis_parser = subparsers.add_parser("thesis", help="Generate a local role-thesis package for a finding.")
102
+ thesis_parser.add_argument("finding_id", help="Finding ID or unique prefix.")
103
+ thesis_parser.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON_OUT), help="Path to JSON artifact.")
104
+ thesis_parser.add_argument("--profile", default=str(DEFAULT_PROFILE), help="Path to profile JSON.")
105
+ thesis_parser.add_argument("--background", default=str(DEFAULT_BACKGROUND), help="Path to private background JSON.")
106
+ thesis_parser.add_argument("--notes", default=str(DEFAULT_NOTES), help="Path to notes JSON.")
107
+ thesis_parser.add_argument("--out-dir", default=str(DEFAULT_THESES_DIR), help="Directory for generated thesis files.")
108
+
109
+ package_parser = subparsers.add_parser("package", help="Export a portable opportunity package for a finding.")
110
+ package_parser.add_argument("finding_id", help="Finding ID or unique prefix to package.")
111
+ package_parser.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON_OUT), help="Path to JSON run artifact.")
112
+ package_parser.add_argument("--out-dir", default=str(DEFAULT_PACKAGE_OUT), help="Directory for package exports.")
113
+
114
+ suppress_parser = subparsers.add_parser("suppress", help="Suppress a finding by ID.")
115
+ suppress_parser.add_argument("finding_id", help="Finding ID or content hash to suppress.")
116
+ suppress_parser.add_argument("--reason", required=True, help="Human-readable suppression reason.")
117
+ suppress_parser.add_argument("--expires", dest="expires_at", help="Optional expiration date in YYYY-MM-DD format.")
118
+ suppress_parser.add_argument("--scope", default="finding", choices=["finding", "company", "source"], help="Suppression scope.")
119
+ suppress_parser.add_argument("--suppressions", default=str(DEFAULT_SUPPRESSIONS), help="Path to suppressions JSON.")
120
+
121
+ doctor_parser = subparsers.add_parser("doctor", help="Validate config, watchlist, and source readiness.")
122
+ add_config_paths(doctor_parser)
123
+ doctor_parser.add_argument("--background", default=str(DEFAULT_BACKGROUND), help="Path to private background JSON.")
124
+
125
+ portfolio_parser = subparsers.add_parser("portfolio", help="Summarize portfolio relationship import.")
126
+ portfolio_parser.add_argument("--portfolio", default=str(DEFAULT_PORTFOLIO), help="Path to portfolio JSON.")
127
+
128
+ radar_parser = subparsers.add_parser("radar", help="Deprecated alias for portfolio.")
129
+ radar_parser.add_argument("--portfolio", default=str(DEFAULT_PORTFOLIO), help="Path to portfolio JSON.")
130
+
131
+ return parser
132
+
133
+
134
+ def add_config_paths(parser: argparse.ArgumentParser) -> None:
135
+ parser.add_argument("--profile", default=str(DEFAULT_PROFILE), help="Path to profile JSON.")
136
+ parser.add_argument("--sources", "--config", dest="sources", default=str(DEFAULT_SOURCES), help="Path to sources JSON.")
137
+ parser.add_argument("--watchlist", default=str(DEFAULT_WATCHLIST), help="Path to watchlist JSON.")
138
+ parser.add_argument("--suppressions", default=str(DEFAULT_SUPPRESSIONS), help="Path to suppressions JSON.")
139
+
140
+
141
+ def add_startup_paths(parser: argparse.ArgumentParser) -> None:
142
+ add_config_paths(parser)
143
+ parser.add_argument("--background", default=str(DEFAULT_BACKGROUND), help="Path to private background JSON.")
144
+ parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to SQLite DB.")
145
+ parser.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON_OUT), help="Path to JSON artifact.")
146
+ parser.add_argument("--notes", default=str(DEFAULT_NOTES), help="Path to notes JSON.")
147
+ parser.add_argument("--theses-dir", default=str(DEFAULT_THESES_DIR), help="Directory for generated thesis files.")
148
+
149
+
150
+ def collect_onboarding_answers(environment: str | None, role: str | None, no_input: bool) -> tuple[str, str]:
151
+ if no_input:
152
+ return environment or "", role or ""
153
+ if environment is not None and role is not None:
154
+ return environment.strip(), role.strip()
155
+ if not sys.stdin.isatty():
156
+ return environment or "", role or ""
157
+ resolved_environment = environment.strip() if environment is not None else input("What is the right environment for you? ").strip()
158
+ resolved_role = role.strip() if role is not None else input("What is the right role for you? ").strip()
159
+ return resolved_environment, resolved_role
160
+
161
+
162
+ def setup_command(
163
+ profile_path: Path,
164
+ sources_path: Path,
165
+ watchlist_path: Path,
166
+ suppressions_path: Path,
167
+ background_path: Path,
168
+ ) -> int:
169
+ if not sys.stdin.isatty():
170
+ print("Setup requires an interactive terminal. Run `pathscout setup` locally, or edit config/profile.json directly.")
171
+ return 2
172
+ ensure_default_files(profile_path, sources_path, watchlist_path, suppressions_path, DEFAULT_PORTFOLIO, background_path)
173
+
174
+ print("PathScout setup")
175
+ print("Press Enter to skip optional prompts. Answers are saved after each step.")
176
+ print("")
177
+
178
+ profile = read_json_or_empty(profile_path)
179
+ background = load_setup_background(background_path)
180
+
181
+ setup_profile(profile_path, profile)
182
+ setup_background(background_path, background)
183
+
184
+ print("")
185
+ print("Setup saved.")
186
+ print("Next: run `pathscout doctor`, then `pathscout run --dry-run --format both`.")
187
+ return 0
188
+
189
+
190
+ def setup_profile(profile_path: Path, profile: dict[str, object]) -> None:
191
+ environment = prompt_for_field(
192
+ profile,
193
+ "environment_preferences",
194
+ "1. What is the right environment for you? ",
195
+ list_field=True,
196
+ )
197
+ if environment:
198
+ write_json_file(profile_path, profile)
199
+
200
+ role = prompt_for_field(
201
+ profile,
202
+ "role_preferences",
203
+ "2. What is the right role/function for you? ",
204
+ list_field=True,
205
+ )
206
+ if role:
207
+ target_roles = profile.setdefault("target_roles", [])
208
+ if isinstance(target_roles, list):
209
+ normalized_roles = {str(value).strip().lower() for value in target_roles}
210
+ for value in clean_csv(role):
211
+ if value.lower() not in normalized_roles:
212
+ target_roles.insert(0, value)
213
+ normalized_roles.add(value.lower())
214
+ write_json_file(profile_path, profile)
215
+
216
+ for field, prompt in [
217
+ ("preferred_locations", "3. Preferred locations? "),
218
+ ("exception_locations", "4. Exception locations you would consider? "),
219
+ ("exclude_domains", "5. Domains to exclude? "),
220
+ ]:
221
+ if prompt_for_field(profile, field, prompt, list_field=True):
222
+ write_json_file(profile_path, profile)
223
+
224
+ scoring = profile.setdefault("scoring", {})
225
+ if isinstance(scoring, dict):
226
+ answer = prompt_value("6. Role terms to avoid? ", scoring.get("negative_role_terms"))
227
+ if answer:
228
+ scoring["negative_role_terms"] = clean_csv(answer)
229
+ write_json_file(profile_path, profile)
230
+
231
+
232
+ def setup_background(background_path: Path, background: dict[str, object]) -> None:
233
+ changed = False
234
+ summary = prompt_for_field(
235
+ background,
236
+ "summary",
237
+ "7. Short background summary? ",
238
+ list_field=False,
239
+ )
240
+ changed = bool(summary) or changed
241
+
242
+ for field, prompt in [
243
+ ("strengths", "8. Strengths to match against opportunities? "),
244
+ ("proof_points", "9. Proof points / wins? "),
245
+ ("best_environments", "10. Environments where you do your best work? "),
246
+ ("avoid_environments", "11. Environments to avoid? "),
247
+ ("constraints", "12. Constraints PathScout should remember? "),
248
+ ("network_context", "13. Network context / warm paths? "),
249
+ ]:
250
+ changed = bool(prompt_for_field(background, field, prompt, list_field=True)) or changed
251
+ if changed:
252
+ write_json_file(background_path, background)
253
+
254
+ if changed:
255
+ write_json_file(background_path, background)
256
+
257
+
258
+ def prompt_for_field(data: dict[str, object], field: str, prompt: str, list_field: bool) -> str:
259
+ answer = prompt_value(prompt, data.get(field))
260
+ if not answer:
261
+ return ""
262
+ data[field] = clean_csv(answer) if list_field else answer
263
+ return answer
264
+
265
+
266
+ def prompt_value(prompt: str, current: object | None) -> str:
267
+ current_text = format_current_value(current)
268
+ suffix = f" [{current_text}]" if current_text else ""
269
+ return input(f"{prompt}{suffix} ").strip()
270
+
271
+
272
+ def format_current_value(current: object | None) -> str:
273
+ if isinstance(current, list):
274
+ return ", ".join(str(item).strip() for item in current if str(item).strip())
275
+ return str(current or "").strip()
276
+
277
+
278
+ def load_setup_background(path: Path) -> dict[str, object]:
279
+ if path.exists():
280
+ return read_json_or_empty(path)
281
+ background = default_background()
282
+ background["summary"] = ""
283
+ for field in ["strengths", "proof_points", "best_environments", "avoid_environments", "constraints", "network_context"]:
284
+ background[field] = []
285
+ write_json_file(path, background)
286
+ return background
287
+
288
+
289
+ def clean_csv(value: str) -> list[str]:
290
+ return [item.strip() for item in value.split(",") if item.strip()]
291
+
292
+
293
+ def write_json_file(path: Path, data: dict[str, object]) -> None:
294
+ path.parent.mkdir(parents=True, exist_ok=True)
295
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
296
+
297
+
298
+ def main(argv: list[str] | None = None) -> int:
299
+ parser = build_parser()
300
+ args = parser.parse_args(argv)
301
+
302
+ if args.command == "start":
303
+ print_startup_checklist(
304
+ Path(args.profile),
305
+ Path(args.sources),
306
+ Path(args.watchlist),
307
+ Path(args.suppressions),
308
+ Path(args.background),
309
+ Path(args.db),
310
+ Path(args.json_path),
311
+ Path(args.notes),
312
+ Path(args.theses_dir),
313
+ )
314
+ return 0
315
+
316
+ if args.command in {"next", "/next"}:
317
+ print_next_action(
318
+ Path(args.profile),
319
+ Path(args.sources),
320
+ Path(args.watchlist),
321
+ Path(args.suppressions),
322
+ Path(args.background),
323
+ Path(args.db),
324
+ Path(args.json_path),
325
+ Path(args.notes),
326
+ Path(args.theses_dir),
327
+ )
328
+ return 0
329
+
330
+ if args.command == "setup":
331
+ return setup_command(
332
+ Path(args.profile),
333
+ Path(args.sources),
334
+ Path(args.watchlist),
335
+ Path(args.suppressions),
336
+ Path(args.background),
337
+ )
338
+
339
+ if args.command == "init":
340
+ profile_path = Path(args.profile)
341
+ ensure_default_files(
342
+ profile_path,
343
+ Path(args.sources),
344
+ Path(args.watchlist),
345
+ Path(args.suppressions),
346
+ DEFAULT_PORTFOLIO,
347
+ Path(args.background),
348
+ )
349
+ environment, role = collect_onboarding_answers(args.environment, args.role, args.no_input)
350
+ if environment or role:
351
+ apply_onboarding_answers(profile_path, environment, role)
352
+ print(f"Initialized PathScout config in {Path(args.sources).parent}")
353
+ print("Next: run `pathscout setup` to finish guided local configuration.")
354
+ return 0
355
+
356
+ if args.command == "run":
357
+ try:
358
+ with warnings.catch_warnings(record=True) as caught:
359
+ warnings.simplefilter("always", DeprecationWarning)
360
+ config = build_runtime_config(Path(args.sources), Path(args.profile), Path(args.watchlist), Path(args.suppressions))
361
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
362
+ print(f"Setup error: {exc}")
363
+ return 2
364
+ for warning in caught:
365
+ print(f"Warning: {warning.message}")
366
+ _, setup_errors = validate_setup(config, Path(args.watchlist), Path(args.profile), Path(args.sources), Path(args.suppressions))
367
+ if setup_errors:
368
+ for error in setup_errors:
369
+ print(f"Setup error: {error}")
370
+ return 2
371
+
372
+ db_path = Path(args.db)
373
+ db_path.parent.mkdir(parents=True, exist_ok=True)
374
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
375
+ Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
376
+
377
+ conn = connect(db_path)
378
+ init_db(conn)
379
+ result = run_sources(conn, config, dry_run=args.dry_run)
380
+ artifact = build_artifact(
381
+ conn,
382
+ config,
383
+ result,
384
+ window_days=args.digest_window_days,
385
+ dry_run=args.dry_run,
386
+ invocation={
387
+ "command": "run",
388
+ "profile": str(args.profile),
389
+ "sources": str(args.sources),
390
+ "watchlist": str(args.watchlist),
391
+ "suppressions": str(args.suppressions),
392
+ "digest_window_days": args.digest_window_days,
393
+ "format": args.format,
394
+ },
395
+ )
396
+ written = []
397
+ if args.format in {"json", "both"}:
398
+ written.append(write_json_artifact(artifact, Path(args.json_out)))
399
+ if args.format in {"markdown", "both"}:
400
+ written.append(write_markdown_artifact(artifact, Path(args.out)))
401
+ print(f"Fetched {result.fetched_count} items; inserted {result.inserted_count}; skipped {result.skipped_count}.")
402
+ for path in written:
403
+ print(f"Wrote {path}")
404
+ return 0
405
+
406
+ if args.command == "watchlist":
407
+ watchlist = load_watchlist(Path(args.watchlist))
408
+ summary = summarize_watchlist(watchlist)
409
+ print(f"Companies: {summary['total']}")
410
+ print(f"Needs review: {summary['needs_review']}")
411
+ print("By status:")
412
+ for status, count in summary["by_status"].items():
413
+ print(f" {status}: {count}")
414
+ print("Top domains:")
415
+ for domain, count in summary["top_domains"]:
416
+ print(f" {domain}: {count}")
417
+ if args.status:
418
+ print(f"Companies with status={args.status}:")
419
+ for company in watchlist.get("companies", []):
420
+ if company.get("status") == args.status:
421
+ print(f" - {company.get('name')} ({company.get('stage', 'unknown')}; {company.get('location', 'unknown')})")
422
+ return 0
423
+
424
+ if args.command == "review":
425
+ return review_findings(Path(args.json_path), args.tier, args.include_suppressed, args.limit)
426
+
427
+ if args.command == "explain":
428
+ return explain_finding(Path(args.json_path), Path(args.notes), args.finding_id)
429
+
430
+ if args.command == "notes":
431
+ return notes_command(Path(args.notes), args.finding_id or "", args.company or "", args.add or "")
432
+
433
+ if args.command == "thesis":
434
+ return thesis_command(
435
+ Path(args.json_path),
436
+ Path(args.profile),
437
+ Path(args.background),
438
+ Path(args.notes),
439
+ Path(args.out_dir),
440
+ args.finding_id,
441
+ )
442
+
443
+ if args.command == "package":
444
+ return package_finding(Path(args.json_path), args.finding_id, Path(args.out_dir))
445
+
446
+ if args.command == "suppress":
447
+ return suppress_finding(Path(args.suppressions), args.finding_id, args.reason, args.expires_at, args.scope)
448
+
449
+ if args.command == "doctor":
450
+ try:
451
+ with warnings.catch_warnings(record=True) as caught:
452
+ warnings.simplefilter("always", DeprecationWarning)
453
+ config = build_runtime_config(Path(args.sources), Path(args.profile), Path(args.watchlist), Path(args.suppressions))
454
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
455
+ print(f"Setup error: {exc}")
456
+ return 2
457
+ for warning in caught:
458
+ print(f"Warning: {warning.message}")
459
+ print(format_doctor_report(config, Path(args.watchlist), Path(args.profile), Path(args.sources), Path(args.suppressions), Path(args.background)))
460
+ _, errors = validate_setup(config, Path(args.watchlist), Path(args.profile), Path(args.sources), Path(args.suppressions), Path(args.background))
461
+ return 2 if errors else 0
462
+
463
+ if args.command == "portfolio":
464
+ return print_portfolio(Path(args.portfolio))
465
+
466
+ if args.command == "radar":
467
+ print("Warning: radar is deprecated; use portfolio.")
468
+ return print_portfolio(Path(args.portfolio))
469
+
470
+ parser.print_help()
471
+ return 1
472
+
473
+
474
+ def print_startup_checklist(
475
+ profile_path: Path,
476
+ sources_path: Path,
477
+ watchlist_path: Path,
478
+ suppressions_path: Path,
479
+ background_path: Path,
480
+ db_path: Path,
481
+ json_path: Path,
482
+ notes_path: Path,
483
+ theses_dir: Path,
484
+ ) -> None:
485
+ state = startup_state(
486
+ profile_path,
487
+ sources_path,
488
+ watchlist_path,
489
+ suppressions_path,
490
+ background_path,
491
+ db_path,
492
+ json_path,
493
+ notes_path,
494
+ theses_dir,
495
+ )
496
+ print("PathScout startup")
497
+ print("")
498
+ for index, item in enumerate(state["items"], start=1):
499
+ status = str(item["status"])
500
+ optional = " (optional)" if item.get("optional") and not status.startswith("optional") else ""
501
+ print(f"{index}. {item['label']}: {status}{optional}")
502
+ detail = item.get("detail")
503
+ if detail:
504
+ print(f" {detail}")
505
+ print("")
506
+ print(f"Next step: {state['next_step']}")
507
+ print("")
508
+ print("First-run sequence:")
509
+ for command in first_run_sequence():
510
+ print(f" {command}")
511
+ print("")
512
+ print("Local-only note: PathScout OSS stores state in local files. Network source fetches collect evidence; they are not hosted storage or sync.")
513
+
514
+
515
+ def print_next_action(
516
+ profile_path: Path,
517
+ sources_path: Path,
518
+ watchlist_path: Path,
519
+ suppressions_path: Path,
520
+ background_path: Path,
521
+ db_path: Path,
522
+ json_path: Path,
523
+ notes_path: Path,
524
+ theses_dir: Path,
525
+ ) -> None:
526
+ state = startup_state(
527
+ profile_path,
528
+ sources_path,
529
+ watchlist_path,
530
+ suppressions_path,
531
+ background_path,
532
+ db_path,
533
+ json_path,
534
+ notes_path,
535
+ theses_dir,
536
+ )
537
+ item = next_startup_item(state["items"])
538
+ print("PathScout next")
539
+ print("")
540
+ print(f"Step: {item['label']}")
541
+ print(f"Status: {item['status']}")
542
+ print(f"Action: {item['detail'] or state['next_step']}")
543
+ print("")
544
+ print("Run `pathscout start` for the full checklist.")
545
+
546
+
547
+ def startup_state(
548
+ profile_path: Path,
549
+ sources_path: Path,
550
+ watchlist_path: Path,
551
+ suppressions_path: Path,
552
+ background_path: Path,
553
+ db_path: Path,
554
+ json_path: Path,
555
+ notes_path: Path,
556
+ theses_dir: Path,
557
+ ) -> dict[str, object]:
558
+ profile = read_json_or_empty(profile_path)
559
+ watchlist = read_json_or_empty(watchlist_path)
560
+ sources = read_json_or_empty(sources_path)
561
+ doctor_ready = startup_setup_valid(profile_path, sources_path, watchlist_path, suppressions_path, background_path)
562
+ items = [
563
+ checklist_item(
564
+ "Initialize local config",
565
+ all(path.exists() for path in [profile_path, sources_path, watchlist_path, suppressions_path]),
566
+ f"Run `pathscout init` to create config files." if not profile_path.exists() else "",
567
+ ),
568
+ checklist_item(
569
+ "Answer environment and role",
570
+ bool(profile.get("environment_preferences")) and bool(profile.get("role_preferences")),
571
+ "Run `pathscout init` interactively or pass `--environment` and `--role`.",
572
+ ),
573
+ checklist_item(
574
+ "Review watchlist",
575
+ bool(watchlist.get("companies")),
576
+ f"Edit {watchlist_path} with companies you want PathScout to monitor.",
577
+ ),
578
+ checklist_item(
579
+ "Review sources",
580
+ bool(sources.get("sources")),
581
+ f"Edit {sources_path} to enable watchlist, careers, RSS, web page, portfolio, or manual sources.",
582
+ ),
583
+ checklist_item(
584
+ "Add private background",
585
+ background_path.exists(),
586
+ f"Optional: copy {DEFAULT_BACKGROUND_SAMPLE} to {background_path} and add proof points.",
587
+ optional=True,
588
+ ),
589
+ checklist_item(
590
+ "Validate setup",
591
+ doctor_ready,
592
+ "Run `pathscout doctor` after editing config.",
593
+ ),
594
+ checklist_item(
595
+ "Run first scan",
596
+ json_path.exists() or db_path.exists(),
597
+ "Run `pathscout run --dry-run --format both`, then `pathscout run --format both` when ready.",
598
+ ),
599
+ checklist_item(
600
+ "Review findings",
601
+ False,
602
+ "Run `pathscout review` after a JSON artifact exists.",
603
+ ready=json_path.exists(),
604
+ ),
605
+ checklist_item(
606
+ "Explain one finding",
607
+ False,
608
+ "Run `pathscout explain <finding-id>` using an ID from `pathscout review`.",
609
+ ready=json_path.exists(),
610
+ ),
611
+ checklist_item(
612
+ "Add local judgment",
613
+ notes_path.exists(),
614
+ "Run `pathscout notes <finding-id> --add \"...\"` or `pathscout notes --company \"...\" --add \"...\"`.",
615
+ ),
616
+ checklist_item(
617
+ "Draft first role thesis",
618
+ theses_dir.exists() and any(theses_dir.glob("*.md")),
619
+ "Run `pathscout thesis <finding-id>` after reviewing and explaining a finding.",
620
+ ready=json_path.exists(),
621
+ ),
622
+ ]
623
+ return {"items": items, "next_step": next_startup_step(items)}
624
+
625
+
626
+ def checklist_item(label: str, done: bool, detail: str, optional: bool = False, ready: bool = False) -> dict[str, object]:
627
+ if optional and not done:
628
+ status = "optional, not found"
629
+ elif ready and not done:
630
+ status = "ready"
631
+ else:
632
+ status = "done" if done else "needs action"
633
+ return {"label": label, "status": status, "detail": detail if not done else "", "optional": optional, "done": done}
634
+
635
+
636
+ def next_startup_step(items: list[dict[str, object]]) -> str:
637
+ item = next_startup_item(items)
638
+ detail = str(item.get("detail") or "")
639
+ return detail if detail else str(item["label"])
640
+
641
+
642
+ def next_startup_item(items: list[dict[str, object]]) -> dict[str, object]:
643
+ for item in items:
644
+ if item.get("optional"):
645
+ continue
646
+ if not item.get("done"):
647
+ return item
648
+ return {
649
+ "label": "Keep reviewing opportunities",
650
+ "status": "ready",
651
+ "detail": "Run `pathscout review`, choose a finding, then run `pathscout thesis <finding-id>`.",
652
+ "optional": False,
653
+ "done": False,
654
+ }
655
+
656
+
657
+ def first_run_sequence() -> list[str]:
658
+ return [
659
+ "pathscout init",
660
+ "edit config/profile.json and config/watchlist.json",
661
+ "optional: copy config/background.sample.json to config/background.local.json",
662
+ "pathscout doctor",
663
+ "pathscout run --dry-run --format both",
664
+ "pathscout run --format both",
665
+ "pathscout review",
666
+ "pathscout explain <finding-id>",
667
+ "pathscout notes <finding-id> --add \"...\"",
668
+ "pathscout thesis <finding-id>",
669
+ ]
670
+
671
+
672
+ def read_json_or_empty(path: Path) -> dict[str, object]:
673
+ if not path.exists():
674
+ return {}
675
+ try:
676
+ with path.open("r", encoding="utf-8") as handle:
677
+ data = json.load(handle)
678
+ except (OSError, json.JSONDecodeError):
679
+ return {}
680
+ return data if isinstance(data, dict) else {}
681
+
682
+
683
+ def startup_setup_valid(
684
+ profile_path: Path,
685
+ sources_path: Path,
686
+ watchlist_path: Path,
687
+ suppressions_path: Path,
688
+ background_path: Path,
689
+ ) -> bool:
690
+ try:
691
+ config = build_runtime_config(sources_path, profile_path, watchlist_path, suppressions_path)
692
+ _, errors = validate_setup(config, watchlist_path, profile_path, sources_path, suppressions_path, background_path)
693
+ except (OSError, ValueError, json.JSONDecodeError):
694
+ return False
695
+ return not errors
696
+
697
+
698
+ def print_portfolio(path: Path) -> int:
699
+ if not path.exists():
700
+ print(f"Missing portfolio file: {path}")
701
+ return 2
702
+ with path.open("r", encoding="utf-8") as handle:
703
+ portfolio = json.load(handle)
704
+ companies = portfolio.get("companies", [])
705
+ print(f"Portfolio relationship companies: {len(companies)}")
706
+ for company in companies:
707
+ print(f" - {company.get('name')} ({company.get('stage', 'unknown')}; {company.get('status', 'relationship')})")
708
+ return 0
709
+
710
+
711
+ def review_findings(path: Path, tier: str | None, include_suppressed: bool, limit: int) -> int:
712
+ if not path.exists():
713
+ print(f"Missing JSON artifact: {path}")
714
+ return 2
715
+ with path.open("r", encoding="utf-8") as handle:
716
+ artifact = json.load(handle)
717
+ findings = artifact.get("findings", [])
718
+ if tier:
719
+ findings = [finding for finding in findings if finding.get("tier") == tier]
720
+ if not include_suppressed:
721
+ findings = [finding for finding in findings if not finding.get("suppressed")]
722
+
723
+ print(f"Findings: {len(findings)}")
724
+ for finding in findings[:limit]:
725
+ finding_id = finding.get("id", "")[:12]
726
+ title = finding.get("title") or "Untitled signal"
727
+ company = finding.get("company") or "Unknown company"
728
+ tier_name = finding.get("tier") or "Unknown"
729
+ score = finding.get("score", 0)
730
+ strength = finding.get("evidence_strength", "medium")
731
+ url = finding.get("url") or ""
732
+ suffix = f" | {url}" if url else ""
733
+ print(f"{finding_id} | {tier_name} | {score} | {strength} | {company} | {title}{suffix}")
734
+ if len(findings) > limit:
735
+ print(f"... {len(findings) - limit} more")
736
+ return 0
737
+
738
+
739
+ def explain_finding(artifact_path: Path, notes_path: Path, finding_id: str) -> int:
740
+ try:
741
+ artifact = load_artifact(artifact_path)
742
+ finding = find_finding(artifact, finding_id)
743
+ notes = related_notes(load_notes(notes_path), finding)
744
+ except (FileNotFoundError, ValueError, json.JSONDecodeError, LookupError) as exc:
745
+ print(f"Explain error: {exc}")
746
+ return 2
747
+ print(render_explanation(finding, notes))
748
+ return 0
749
+
750
+
751
+ def notes_command(notes_path: Path, finding_id: str, company: str, body: str) -> int:
752
+ try:
753
+ if body:
754
+ entry = add_note(notes_path, body, finding_id=finding_id, company=company)
755
+ target = entry.get("finding_id") or entry.get("company")
756
+ print(f"Added note {entry['id']} for {target}")
757
+ return 0
758
+ if not finding_id and not company:
759
+ print("Notes error: provide a finding ID or --company")
760
+ return 2
761
+ notes = related_notes(load_notes(notes_path), {"id": finding_id, "company": company}, company=company)
762
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
763
+ print(f"Notes error: {exc}")
764
+ return 2
765
+ print(render_notes(notes))
766
+ return 0
767
+
768
+
769
+ def thesis_command(
770
+ artifact_path: Path,
771
+ profile_path: Path,
772
+ background_path: Path,
773
+ notes_path: Path,
774
+ out_dir: Path,
775
+ finding_id: str,
776
+ ) -> int:
777
+ try:
778
+ artifact = load_artifact(artifact_path)
779
+ finding = find_finding(artifact, finding_id)
780
+ profile = load_profile(profile_path)
781
+ background = load_background(background_path)
782
+ notes = related_notes(load_notes(notes_path), finding)
783
+ path = write_thesis(finding, profile, background, notes, out_dir)
784
+ except (FileNotFoundError, ValueError, json.JSONDecodeError, LookupError, OSError) as exc:
785
+ print(f"Thesis error: {exc}")
786
+ return 2
787
+ if not background:
788
+ print(f"Warning: missing optional background file: {background_path}")
789
+ print(f"Wrote {path}")
790
+ return 0
791
+
792
+
793
+ def package_finding(path: Path, finding_id: str, out_dir: Path) -> int:
794
+ if not path.exists():
795
+ print(f"Missing JSON artifact: {path}")
796
+ return 2
797
+ with path.open("r", encoding="utf-8") as handle:
798
+ artifact = json.load(handle)
799
+ try:
800
+ package_dir = write_package_from_artifact(artifact, finding_id, out_dir)
801
+ except ValueError as exc:
802
+ print(str(exc))
803
+ return 2
804
+ print(f"Wrote package {package_dir}")
805
+ return 0
806
+
807
+
808
+ def suppress_finding(path: Path, finding_id: str, reason: str, expires_at: str | None, scope: str) -> int:
809
+ if expires_at:
810
+ try:
811
+ date.fromisoformat(expires_at)
812
+ except ValueError:
813
+ print(f"Invalid --expires date: {expires_at}. Use YYYY-MM-DD.")
814
+ return 2
815
+
816
+ if path.exists():
817
+ with path.open("r", encoding="utf-8") as handle:
818
+ suppressions = json.load(handle)
819
+ else:
820
+ suppressions = {"schema_version": 1, "suppressions": []}
821
+
822
+ entries = suppressions.setdefault("suppressions", [])
823
+ existing = next((entry for entry in entries if entry.get("id") == finding_id and entry.get("scope", "finding") == scope), None)
824
+ entry = {
825
+ "id": finding_id,
826
+ "scope": scope,
827
+ "reason": reason,
828
+ "created_at": date.today().isoformat(),
829
+ }
830
+ if expires_at:
831
+ entry["expires_at"] = expires_at
832
+ if existing is not None:
833
+ existing.update(entry)
834
+ print(f"Updated suppression for {finding_id}")
835
+ else:
836
+ entries.append(entry)
837
+ print(f"Added suppression for {finding_id}")
838
+
839
+ path.parent.mkdir(parents=True, exist_ok=True)
840
+ path.write_text(json.dumps(suppressions, indent=2) + "\n", encoding="utf-8")
841
+ return 0