truecourse 0.5.0 → 0.5.2

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.
package/README.md CHANGED
@@ -90,12 +90,26 @@ truecourse rules llm --disable # Disable LLM rules
90
90
 
91
91
  ### Git Hooks
92
92
 
93
- TrueCourse can install a pre-commit hook that blocks commits with critical violations:
93
+ TrueCourse can install a pre-commit hook that blocks commits introducing new violations at or above a configured severity:
94
94
 
95
95
  ```bash
96
96
  truecourse hooks install # Install pre-commit hook
97
97
  truecourse hooks uninstall # Remove pre-commit hook
98
- truecourse hooks status # Show hook installation status
98
+ truecourse hooks status # Show hook status + config
99
+ ```
100
+
101
+ On every commit the hook runs `truecourse analyze --diff` against the repo's last full analysis and blocks if any newly-introduced violation matches the configured block severities. **Commits will take as long as a full diff analysis** — on large repos that can be tens of seconds per commit. `truecourse hooks install` warns you and requires confirmation before writing the hook.
102
+
103
+ **First-time setup:** run `truecourse analyze` once to establish a baseline. Without it the hook can't diff.
104
+
105
+ **Bypass:** `git commit --no-verify` (standard git).
106
+
107
+ **Configuration** — `hooks install` seeds `<repo>/.truecourse/hooks.yaml` with starter defaults; commit the file so your team shares one policy. The hook reads only from this file — if you delete it, the hook warns and passes every commit (no hidden code-level defaults). Current shape:
108
+
109
+ ```yaml
110
+ pre-commit:
111
+ block-on: [critical, high] # severities. Valid: info|low|medium|high|critical
112
+ llm: false # run LLM rules on every commit (tokens per commit)
99
113
  ```
100
114
 
101
115
  ### Telemetry
@@ -131,13 +145,14 @@ TrueCourse ships with **1,200+ deterministic rules** and **100 LLM rules** acros
131
145
 
132
146
  TrueCourse includes [Claude Code skills](https://docs.anthropic.com/en/docs/claude-code/skills) for conversational analysis from within Claude Code.
133
147
 
134
- Run `truecourse add` to install skills to `.claude/skills/truecourse/` in your project.
148
+ The first `truecourse analyze` (or `truecourse add`) in a fresh repo asks whether to install skills into `.claude/skills/truecourse/`. Re-runs skip the prompt if skills are already present. Pass `--install-skills` / `--no-skills` to bypass the prompt explicitly.
135
149
 
136
150
  | Skill | What it does |
137
151
  |---|---|
138
152
  | `/truecourse-analyze` | Runs analysis or diff check, summarizes results |
139
153
  | `/truecourse-list` | Shows full violation details |
140
154
  | `/truecourse-fix` | Lists fixable violations, applies changes |
155
+ | `/truecourse-hooks` | Installs, configures, or removes the pre-commit hook |
141
156
 
142
157
  ## Language Support
143
158
 
@@ -172,6 +187,10 @@ pnpm build # Build all packages
172
187
 
173
188
  TrueCourse collects anonymous usage data (event type, language, file count range, OS). No source code, file paths, or violation details are collected. Opt out with `truecourse telemetry disable` or `TRUECOURSE_TELEMETRY=0`.
174
189
 
190
+ ## Contact
191
+
192
+ Questions, feedback, or security reports: **Mushegh Gevorgyan** — [mushegh@truecourse.dev](mailto:mushegh@truecourse.dev).
193
+
175
194
  ## License
176
195
 
177
196
  MIT
package/cli.mjs CHANGED
@@ -4062,8 +4062,11 @@ var init_registry = __esm({
4062
4062
  // tools/cli/src/commands/helpers.ts
4063
4063
  var helpers_exports = {};
4064
4064
  __export(helpers_exports, {
4065
+ exitMissingNonInteractiveFlag: () => exitMissingNonInteractiveFlag,
4065
4066
  getConfigPath: () => getConfigPath,
4066
4067
  getServerUrl: () => getServerUrl,
4068
+ hasInstalledSkills: () => hasInstalledSkills,
4069
+ isInteractive: () => isInteractive,
4067
4070
  openInBrowser: () => openInBrowser,
4068
4071
  promptInstallSkills: () => promptInstallSkills,
4069
4072
  readConfig: () => readConfig,
@@ -4316,11 +4319,24 @@ function openInBrowser(url) {
4316
4319
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4317
4320
  exec2(`${cmd} ${url}`);
4318
4321
  }
4319
- async function promptInstallSkills(repoPath) {
4320
- const installSkills = await ot2({
4321
- message: "Would you like to install Claude Code skills?"
4322
- });
4323
- if (q(installSkills) || !installSkills) return;
4322
+ function skillDestPath(repoPath) {
4323
+ return resolve(repoPath, ".claude", "skills", "truecourse");
4324
+ }
4325
+ function hasInstalledSkills(repoPath) {
4326
+ return existsSync(skillDestPath(repoPath));
4327
+ }
4328
+ function isInteractive() {
4329
+ return !!process.stdin.isTTY;
4330
+ }
4331
+ function exitMissingNonInteractiveFlag(context, flagGuidance) {
4332
+ O2.error(
4333
+ `${context}
4334
+
4335
+ Running non-interactively with no answer. ${flagGuidance}`
4336
+ );
4337
+ process.exit(1);
4338
+ }
4339
+ function copySkillsInto(repoPath) {
4324
4340
  const __dirname4 = dirname(fileURLToPath(import.meta.url));
4325
4341
  const srcPath = resolve(__dirname4, "..", "..", "skills", "truecourse");
4326
4342
  const distPath = resolve(__dirname4, "skills", "truecourse");
@@ -4336,6 +4352,20 @@ async function promptInstallSkills(repoPath) {
4336
4352
  O2.message(" - truecourse-list (list violations)");
4337
4353
  O2.message(" - truecourse-fix (apply fixes)");
4338
4354
  }
4355
+ async function promptInstallSkills(repoPath, { install } = {}) {
4356
+ if (hasInstalledSkills(repoPath)) return;
4357
+ if (install === true) {
4358
+ copySkillsInto(repoPath);
4359
+ return;
4360
+ }
4361
+ if (install === false) return;
4362
+ if (!isInteractive()) return;
4363
+ const answer = await ot2({
4364
+ message: "Would you like to install Claude Code skills?"
4365
+ });
4366
+ if (q(answer) || !answer) return;
4367
+ copySkillsInto(repoPath);
4368
+ }
4339
4369
  var DEFAULT_PORT, DEFAULT_CONFIG;
4340
4370
  var init_helpers = __esm({
4341
4371
  "tools/cli/src/commands/helpers.ts"() {
@@ -126557,7 +126587,7 @@ init_dist4();
126557
126587
  init_paths();
126558
126588
  init_registry();
126559
126589
  init_helpers();
126560
- async function runAdd() {
126590
+ async function runAdd(options = {}) {
126561
126591
  const repoPath = resolveRepoDir(process.cwd()) ?? process.cwd();
126562
126592
  mt("Adding repository to TrueCourse");
126563
126593
  O2.step(repoPath);
@@ -126569,7 +126599,7 @@ async function runAdd() {
126569
126599
  } else {
126570
126600
  O2.success(`Repository "${entry.name}" added.`);
126571
126601
  }
126572
- await promptInstallSkills(repoPath);
126602
+ await promptInstallSkills(repoPath, { install: options.installSkills });
126573
126603
  gt("Run `truecourse analyze` to generate analysis data.");
126574
126604
  }
126575
126605
 
@@ -126598,12 +126628,20 @@ init_helpers();
126598
126628
 
126599
126629
  // tools/cli/src/commands/llm-prompt.ts
126600
126630
  init_dist4();
126601
- async function promptLlmEstimate(estimate) {
126631
+ init_helpers();
126632
+ async function promptLlmEstimate(estimate, { autoApprove } = {}) {
126602
126633
  const totalRules = estimate.uniqueRuleCount ?? estimate.tiers.reduce((s, t2) => s + t2.ruleCount, 0);
126603
126634
  const totalFiles = estimate.uniqueFileCount ?? estimate.tiers.reduce((s, t2) => s + t2.fileCount, 0);
126604
126635
  const tokens = estimate.totalEstimatedTokens;
126605
126636
  const tokenStr = tokens >= 1e6 ? `~${(tokens / 1e6).toFixed(1)}M tokens` : `~${Math.round(tokens / 1e3)}k tokens`;
126606
126637
  O2.step(`LLM will analyze ${totalFiles} files with ${totalRules} rules (${tokenStr})`);
126638
+ if (autoApprove) return true;
126639
+ if (!isInteractive()) {
126640
+ O2.error(
126641
+ "Cannot prompt for LLM-rule confirmation non-interactively. Pass --llm to approve the estimate or --no-llm to skip LLM rules."
126642
+ );
126643
+ return false;
126644
+ }
126607
126645
  const proceed = await ot2({ message: "Run LLM-powered rules?", initialValue: true });
126608
126646
  if (q(proceed)) return false;
126609
126647
  if (!proceed) O2.info("Skipping LLM rules.");
@@ -126751,18 +126789,31 @@ function stopSpinner() {
126751
126789
  spinnerInterval = null;
126752
126790
  }
126753
126791
  }
126754
- async function runAnalyze(_options = {}) {
126792
+ function resolveLlmDecision(options, configDefault) {
126793
+ if (options.llm === true) return { enabled: true, autoApproveEstimate: true };
126794
+ if (options.llm === false) return { enabled: false, autoApproveEstimate: false };
126795
+ if (!isInteractive()) {
126796
+ exitMissingNonInteractiveFlag(
126797
+ "analyze needs a decision on LLM rules before running non-interactively.",
126798
+ "Pass --llm to run with LLM rules (cost) or --no-llm to skip them."
126799
+ );
126800
+ }
126801
+ return { enabled: configDefault, autoApproveEstimate: false };
126802
+ }
126803
+ async function runAnalyze(options = {}) {
126755
126804
  mt("Analyzing repository");
126756
126805
  ensureClaudeCli();
126757
126806
  showFirstRunNotice();
126758
126807
  const project = resolveOrInitProject();
126759
126808
  O2.step(`Repository: ${project.name}`);
126809
+ await promptInstallSkills(project.path, { install: options.installSkills });
126760
126810
  configureLogger({
126761
126811
  filePath: path16.join(project.path, ".truecourse/logs/analyze.log")
126762
126812
  });
126763
126813
  const config2 = readProjectConfig(project.path);
126764
126814
  const enabledCategories = config2.enabledCategories ?? void 0;
126765
- const enableLlmRules = config2.enableLlmRules ?? true;
126815
+ const llmDecision = resolveLlmDecision(options, config2.enableLlmRules ?? true);
126816
+ const enableLlmRules = llmDecision.enabled;
126766
126817
  renderPhase = enableLlmRules ? "pre-llm" : "all";
126767
126818
  if (wipeLegacyPostgresData()) {
126768
126819
  O2.info("Legacy Postgres data wiped. Re-analyze to repopulate.");
@@ -126782,7 +126833,9 @@ async function runAnalyze(_options = {}) {
126782
126833
  enableLlmRulesOverride: enableLlmRules,
126783
126834
  onLlmEstimate: async (estimate) => {
126784
126835
  stopSpinner();
126785
- const proceed = await promptLlmEstimate(estimate);
126836
+ const proceed = await promptLlmEstimate(estimate, {
126837
+ autoApprove: llmDecision.autoApproveEstimate
126838
+ });
126786
126839
  renderPhase = "post-llm";
126787
126840
  return proceed;
126788
126841
  }
@@ -126804,7 +126857,7 @@ async function runAnalyze(_options = {}) {
126804
126857
  await closeLogger();
126805
126858
  }
126806
126859
  }
126807
- async function runAnalyzeDiff(_options = {}) {
126860
+ async function runAnalyzeDiff(options = {}) {
126808
126861
  const { diffInProcess: diffInProcess2 } = await Promise.resolve().then(() => (init_diff_in_process(), diff_in_process_exports));
126809
126862
  const { renderDiffResultsSummary: renderDiffResultsSummary2 } = await Promise.resolve().then(() => (init_helpers(), helpers_exports));
126810
126863
  mt("Running diff check");
@@ -126812,12 +126865,14 @@ async function runAnalyzeDiff(_options = {}) {
126812
126865
  showFirstRunNotice();
126813
126866
  const project = resolveOrInitProject();
126814
126867
  O2.step(`Repository: ${project.name}`);
126868
+ await promptInstallSkills(project.path, { install: options.installSkills });
126815
126869
  configureLogger({
126816
126870
  filePath: path16.join(project.path, ".truecourse/logs/analyze.log")
126817
126871
  });
126818
126872
  const config2 = readProjectConfig(project.path);
126819
126873
  const enabledCategories = config2.enabledCategories ?? void 0;
126820
- const enableLlmRules = config2.enableLlmRules ?? true;
126874
+ const llmDecision = resolveLlmDecision(options, config2.enableLlmRules ?? true);
126875
+ const enableLlmRules = llmDecision.enabled;
126821
126876
  renderPhase = enableLlmRules ? "pre-llm" : "all";
126822
126877
  const stepDefs = buildAnalysisSteps(enabledCategories, enableLlmRules);
126823
126878
  const tracker = new StepTracker((payload) => {
@@ -126834,7 +126889,9 @@ async function runAnalyzeDiff(_options = {}) {
126834
126889
  enableLlmRulesOverride: enableLlmRules,
126835
126890
  onLlmEstimate: async (estimate) => {
126836
126891
  stopSpinner();
126837
- const proceed = await promptLlmEstimate(estimate);
126892
+ const proceed = await promptLlmEstimate(estimate, {
126893
+ autoApprove: llmDecision.autoApproveEstimate
126894
+ });
126838
126895
  renderPhase = "post-llm";
126839
126896
  return proceed;
126840
126897
  }
@@ -127370,6 +127427,27 @@ async function runServiceMode(serverEntry) {
127370
127427
  O2.success(`Dashboard open at ${target}`);
127371
127428
  O2.info("Stop the dashboard with: truecourse dashboard stop");
127372
127429
  }
127430
+ async function probeHealth(url) {
127431
+ try {
127432
+ const res = await fetch(`${url}/api/health`);
127433
+ return res.ok;
127434
+ } catch {
127435
+ return false;
127436
+ }
127437
+ }
127438
+ async function detectRunningState() {
127439
+ const platform = getPlatform();
127440
+ const url = getServerUrl();
127441
+ const healthy = await probeHealth(url);
127442
+ if (await platform.isInstalled()) {
127443
+ const { running, pid } = await platform.status();
127444
+ if (running) return { mode: "service", pid, healthy };
127445
+ if (healthy) return { mode: "console", healthy: true };
127446
+ return { mode: "none" };
127447
+ }
127448
+ if (healthy) return { mode: "console", healthy: true };
127449
+ return { mode: "none" };
127450
+ }
127373
127451
  async function runDashboard(options = {}) {
127374
127452
  mt("Opening TrueCourse dashboard");
127375
127453
  const serverEntry = resolveServerEntry();
@@ -127380,9 +127458,34 @@ async function runDashboard(options = {}) {
127380
127458
  process.exit(1);
127381
127459
  }
127382
127460
  const configured = fs15.existsSync(getConfigPath());
127383
- const shouldPrompt = !configured || options.reconfigure;
127384
- const runMode = shouldPrompt ? await promptRunMode() : readConfig().runMode;
127385
- if (shouldPrompt) writeConfig({ runMode });
127461
+ const needsDecision = !configured || options.reconfigure;
127462
+ let runMode;
127463
+ if (options.mode) {
127464
+ runMode = options.mode;
127465
+ } else if (needsDecision) {
127466
+ if (!isInteractive()) {
127467
+ exitMissingNonInteractiveFlag(
127468
+ "Dashboard run mode is not configured.",
127469
+ "Pass --service for the background service or --console to run in this terminal."
127470
+ );
127471
+ }
127472
+ runMode = await promptRunMode();
127473
+ } else {
127474
+ runMode = readConfig().runMode;
127475
+ }
127476
+ const shouldPersist = needsDecision || options.mode !== void 0;
127477
+ if (runMode !== "service") {
127478
+ const platform = getPlatform();
127479
+ if (await platform.isInstalled()) {
127480
+ const { running } = await platform.status();
127481
+ if (running) {
127482
+ O2.error(
127483
+ "A dashboard service is already installed and running. Stop and remove it first: `truecourse dashboard uninstall`, then rerun `truecourse dashboard`."
127484
+ );
127485
+ process.exit(1);
127486
+ }
127487
+ }
127488
+ }
127386
127489
  if (runMode === "service") {
127387
127490
  try {
127388
127491
  await runServiceMode(serverEntry);
@@ -127390,70 +127493,68 @@ async function runDashboard(options = {}) {
127390
127493
  O2.error(`Service mode failed: ${err.message}`);
127391
127494
  O2.info("Falling back to console mode.");
127392
127495
  await runConsoleMode(serverEntry);
127496
+ if (shouldPersist) writeConfig({ runMode: "console" });
127497
+ return;
127393
127498
  }
127499
+ if (shouldPersist) writeConfig({ runMode: "service" });
127394
127500
  } else {
127395
127501
  await runConsoleMode(serverEntry);
127502
+ if (shouldPersist) writeConfig({ runMode: "console" });
127396
127503
  }
127397
127504
  }
127398
127505
  async function runDashboardStop() {
127399
- const config2 = readConfig();
127400
- if (config2.runMode !== "service") {
127401
- O2.info("Dashboard is running in console mode. Press Ctrl+C in its terminal to stop.");
127402
- return;
127403
- }
127404
- const platform = getPlatform();
127405
- const { running } = await platform.status();
127406
- if (!running) {
127407
- O2.info("Dashboard is not running.");
127408
- return;
127506
+ const state = await detectRunningState();
127507
+ switch (state.mode) {
127508
+ case "service": {
127509
+ O2.step("Stopping dashboard service...");
127510
+ await getPlatform().stop();
127511
+ O2.success("Dashboard service stopped.");
127512
+ return;
127513
+ }
127514
+ case "console": {
127515
+ O2.info(
127516
+ "A dashboard is running in console mode (not managed by the service). Press Ctrl+C in its terminal to stop it."
127517
+ );
127518
+ return;
127519
+ }
127520
+ case "none": {
127521
+ O2.info("Dashboard is not running.");
127522
+ return;
127523
+ }
127409
127524
  }
127410
- O2.step("Stopping dashboard...");
127411
- await platform.stop();
127412
- O2.success("Dashboard stopped.");
127413
127525
  }
127414
127526
  async function runDashboardStatus() {
127415
- const config2 = readConfig();
127527
+ const state = await detectRunningState();
127416
127528
  const url = getServerUrl();
127417
- if (config2.runMode !== "service") {
127418
- try {
127419
- const res = await fetch(`${url}/api/health`);
127420
- if (res.ok) {
127421
- O2.success(`Dashboard is running in console mode at ${url}`);
127529
+ switch (state.mode) {
127530
+ case "service": {
127531
+ const pidInfo = state.pid ? ` (PID: ${state.pid})` : "";
127532
+ O2.success(`Dashboard service is running${pidInfo}`);
127533
+ if (state.healthy) {
127534
+ O2.info(`Server is healthy at ${url}`);
127422
127535
  } else {
127423
- O2.info("Dashboard is not running.");
127536
+ O2.warn(`Service process is running but server is not responding at ${url}.`);
127424
127537
  }
127425
- } catch {
127426
- O2.info("Dashboard is not running.");
127538
+ return;
127427
127539
  }
127428
- return;
127429
- }
127430
- const platform = getPlatform();
127431
- const installed = await platform.isInstalled();
127432
- if (!installed) {
127433
- O2.info("Dashboard service is not installed. Run `truecourse dashboard` to set it up.");
127434
- return;
127435
- }
127436
- const { running, pid } = await platform.status();
127437
- if (!running) {
127438
- O2.info("Dashboard service is installed but not running.");
127439
- return;
127440
- }
127441
- const pidInfo = pid ? ` (PID: ${pid})` : "";
127442
- O2.success(`Dashboard service is running${pidInfo}`);
127443
- try {
127444
- const res = await fetch(`${url}/api/health`);
127445
- if (res.ok) {
127446
- O2.info(`Server is healthy at ${url}`);
127447
- } else {
127448
- O2.warn(`Server returned status ${res.status}`);
127540
+ case "console": {
127541
+ O2.success(`Dashboard is running in console mode at ${url}`);
127542
+ return;
127543
+ }
127544
+ case "none": {
127545
+ const platform = getPlatform();
127546
+ if (await platform.isInstalled()) {
127547
+ O2.info("Dashboard service is installed but not running.");
127548
+ } else {
127549
+ O2.info("Dashboard is not running.");
127550
+ }
127551
+ return;
127449
127552
  }
127450
- } catch {
127451
- O2.warn("Service process is running but server is not responding.");
127452
127553
  }
127453
127554
  }
127454
- function runDashboardLogs() {
127455
- const config2 = readConfig();
127456
- if (config2.runMode !== "service") {
127555
+ async function runDashboardLogs() {
127556
+ const state = await detectRunningState();
127557
+ if (state.mode === "console") {
127457
127558
  O2.info("Dashboard is running in console mode \u2014 logs print to its terminal.");
127458
127559
  return;
127459
127560
  }
@@ -127490,6 +127591,7 @@ var SEVERITY_ORDER = {
127490
127591
  low: 3,
127491
127592
  info: 4
127492
127593
  };
127594
+ var SEVERITIES = ["critical", "high", "medium", "low", "info"];
127493
127595
  function listViolations(repoPath, options = {}) {
127494
127596
  const latest = readLatest(repoPath);
127495
127597
  if (!latest)
@@ -127517,6 +127619,10 @@ function listViolations(repoPath, options = {}) {
127517
127619
  const absPath = options.filePath.startsWith("/") ? options.filePath : `${repoPath}/${options.filePath}`;
127518
127620
  filtered = filtered.filter((v) => v.type === "code" && (v.filePath === absPath || v.filePath === options.filePath));
127519
127621
  }
127622
+ if (options.severity !== void 0) {
127623
+ const allowed = (Array.isArray(options.severity) ? options.severity : [options.severity]).map((s) => s.toLowerCase());
127624
+ filtered = filtered.filter((v) => allowed.includes(v.severity.toLowerCase()));
127625
+ }
127520
127626
  filtered.sort((a, b) => {
127521
127627
  const sa = SEVERITY_ORDER[a.severity] ?? 5;
127522
127628
  const sb = SEVERITY_ORDER[b.severity] ?? 5;
@@ -127577,19 +127683,37 @@ function getDiffResult(repoPath) {
127577
127683
 
127578
127684
  // tools/cli/src/commands/list.ts
127579
127685
  init_helpers();
127580
- async function runList({ limit = 20, offset = 0 } = {}) {
127686
+ async function runList({ limit = 20, offset = 0, severity } = {}) {
127581
127687
  mt("Violations");
127582
127688
  const repo = requireRegisteredRepo();
127583
127689
  const { violations, total } = listViolations(repo.path, {
127584
127690
  limit: isFinite(limit) ? limit : 0,
127585
- offset
127691
+ offset,
127692
+ severity
127586
127693
  });
127587
127694
  if (total === 0 && violations.length === 0) {
127588
- O2.info("No violations. Run `truecourse analyze` if you haven't yet.");
127695
+ if (severity && severity.length > 0) {
127696
+ O2.info(`No violations match severity filter: ${severity.join(", ")}.`);
127697
+ } else {
127698
+ O2.info("No violations. Run `truecourse analyze` if you haven't yet.");
127699
+ }
127589
127700
  return;
127590
127701
  }
127591
127702
  renderViolations(violations, { total, offset });
127592
127703
  }
127704
+ function parseSeverityFlag(raw) {
127705
+ if (!raw) return void 0;
127706
+ const parts = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
127707
+ if (parts.length === 0) return void 0;
127708
+ const invalid = parts.filter((s) => !SEVERITIES.includes(s));
127709
+ if (invalid.length > 0) {
127710
+ console.error(
127711
+ `error: unknown severity value(s): ${invalid.join(", ")}. Valid: ${SEVERITIES.join(", ")}`
127712
+ );
127713
+ process.exit(1);
127714
+ }
127715
+ return parts;
127716
+ }
127593
127717
  async function runListDiff() {
127594
127718
  mt("Diff check results");
127595
127719
  const repo = requireRegisteredRepo();
@@ -130310,6 +130434,11 @@ var jsYaml = {
130310
130434
  };
130311
130435
 
130312
130436
  // tools/cli/src/commands/hooks.ts
130437
+ init_dist4();
130438
+ init_paths();
130439
+ init_registry();
130440
+ init_analysis_store();
130441
+ init_diff_in_process();
130313
130442
  init_helpers();
130314
130443
  var HOOK_IDENTIFIER = "# TrueCourse pre-commit hook";
130315
130444
  var HOOK_SCRIPT = `#!/bin/sh
@@ -130317,29 +130446,35 @@ ${HOOK_IDENTIFIER}
130317
130446
  # Installed by: truecourse hooks install
130318
130447
  # Bypass with: git commit --no-verify
130319
130448
 
130320
- exec truecourse hooks run
130449
+ exec npx -y truecourse hooks run
130450
+ `;
130451
+ var SEVERITIES2 = ["info", "low", "medium", "high", "critical"];
130452
+ var HOOKS_YAML_TEMPLATE = `# TrueCourse pre-commit hook config.
130453
+ # Commit this file \u2014 it's the team-shared policy for what blocks a commit.
130454
+ # Check the live values with \`truecourse hooks status\`.
130455
+ pre-commit:
130456
+ # Severities that block a commit when the diff surfaces NEW violations
130457
+ # at that level. Valid: info, low, medium, high, critical.
130458
+ block-on:
130459
+ - critical
130460
+ - high
130461
+
130462
+ # Run LLM-powered rules on every commit? Off by default (no tokens per
130463
+ # commit). Set to true for deeper semantic checks at the commit gate \u2014
130464
+ # each commit will then cost tokens.
130465
+ llm: false
130321
130466
  `;
130322
- var DEFAULT_BLOCK_ON = [
130323
- "security/deterministic/hardcoded-secret",
130324
- { severity: "critical" }
130325
- ];
130326
- var DEFAULT_TIMEOUT_MS = 3e4;
130327
130467
  function findGitDir(from) {
130328
130468
  let dir = from;
130329
130469
  while (true) {
130330
130470
  const gitPath = path21.join(dir, ".git");
130331
130471
  if (fs16.existsSync(gitPath)) {
130332
130472
  const stat = fs16.statSync(gitPath);
130333
- if (stat.isDirectory()) {
130334
- return gitPath;
130335
- }
130473
+ if (stat.isDirectory()) return gitPath;
130336
130474
  if (stat.isFile()) {
130337
130475
  const content = fs16.readFileSync(gitPath, "utf-8").trim();
130338
130476
  const match2 = content.match(/^gitdir:\s*(.+)$/);
130339
- if (match2) {
130340
- const resolved = path21.resolve(dir, match2[1]);
130341
- return resolved;
130342
- }
130477
+ if (match2) return path21.resolve(dir, match2[1]);
130343
130478
  }
130344
130479
  }
130345
130480
  const parent = path21.dirname(dir);
@@ -130350,46 +130485,68 @@ function findGitDir(from) {
130350
130485
  function findProjectRoot(from) {
130351
130486
  let dir = from;
130352
130487
  while (true) {
130353
- if (fs16.existsSync(path21.join(dir, ".git"))) {
130354
- return dir;
130355
- }
130488
+ if (fs16.existsSync(path21.join(dir, ".git"))) return dir;
130356
130489
  const parent = path21.dirname(dir);
130357
130490
  if (parent === dir) return null;
130358
130491
  dir = parent;
130359
130492
  }
130360
130493
  }
130361
- function parseTimeout(value) {
130362
- const match2 = value.match(/^(\d+)\s*(s|ms|m)?$/);
130363
- if (!match2) return DEFAULT_TIMEOUT_MS;
130364
- const num = parseInt(match2[1], 10);
130365
- const unit = match2[2] || "s";
130366
- if (unit === "ms") return num;
130367
- if (unit === "m") return num * 6e4;
130368
- return num * 1e3;
130369
- }
130370
130494
  function loadConfig(projectRoot) {
130371
130495
  const configPath = path21.join(projectRoot, ".truecourse", "hooks.yaml");
130372
- if (!fs16.existsSync(configPath)) return {};
130496
+ if (!fs16.existsSync(configPath)) return null;
130497
+ let parsed;
130373
130498
  try {
130374
130499
  const raw = fs16.readFileSync(configPath, "utf-8");
130375
- return jsYaml.load(raw) || {};
130376
- } catch {
130377
- return {};
130500
+ parsed = jsYaml.load(raw) || {};
130501
+ } catch (err) {
130502
+ console.error(`Error parsing ${configPath}: ${err.message}`);
130503
+ process.exit(1);
130378
130504
  }
130505
+ const preCommit = parsed["pre-commit"] ?? {};
130506
+ const rawBlockOn = preCommit["block-on"];
130507
+ if (!Array.isArray(rawBlockOn)) {
130508
+ console.error(
130509
+ `Invalid ${configPath}: \`pre-commit.block-on\` must be an array of severity names.`
130510
+ );
130511
+ console.error(` Valid severities: ${SEVERITIES2.join(", ")}`);
130512
+ process.exit(1);
130513
+ }
130514
+ const invalid = rawBlockOn.filter(
130515
+ (s) => typeof s !== "string" || !SEVERITIES2.includes(s)
130516
+ );
130517
+ if (invalid.length > 0) {
130518
+ console.error(
130519
+ `Invalid ${configPath}: unknown value(s) in \`pre-commit.block-on\`: ${invalid.map((v) => JSON.stringify(v)).join(", ")}`
130520
+ );
130521
+ console.error(` Valid severities: ${SEVERITIES2.join(", ")}`);
130522
+ process.exit(1);
130523
+ }
130524
+ return {
130525
+ blockOn: rawBlockOn,
130526
+ llm: preCommit.llm === true,
130527
+ configPath
130528
+ };
130379
130529
  }
130380
- function getBlockRules(config2) {
130381
- return config2["pre-commit"]?.["block-on"] ?? DEFAULT_BLOCK_ON;
130382
- }
130383
- function getTimeoutMs(config2) {
130384
- const raw = config2["pre-commit"]?.timeout;
130385
- return raw ? parseTimeout(raw) : DEFAULT_TIMEOUT_MS;
130386
- }
130387
- function runHooksInstall() {
130530
+ var INSTALL_WARNING = "The pre-commit hook runs `truecourse analyze --diff` on every commit.\nCommits will take as long as a full diff analysis of this repo \u2014\non large repos that can be tens of seconds per commit.";
130531
+ async function runHooksInstall() {
130388
130532
  const gitDir = findGitDir(process.cwd());
130389
130533
  if (!gitDir) {
130390
130534
  console.error("Error: Not a git repository.");
130391
130535
  process.exit(1);
130392
130536
  }
130537
+ if (isInteractive()) {
130538
+ O2.warn(INSTALL_WARNING);
130539
+ const proceed = await ot2({
130540
+ message: "Install the pre-commit hook?",
130541
+ initialValue: false
130542
+ });
130543
+ if (q(proceed) || !proceed) {
130544
+ pt("Install cancelled.");
130545
+ process.exit(0);
130546
+ }
130547
+ } else {
130548
+ console.log(INSTALL_WARNING);
130549
+ }
130393
130550
  const hooksDir = path21.join(gitDir, "hooks");
130394
130551
  fs16.mkdirSync(hooksDir, { recursive: true });
130395
130552
  const hookPath = path21.join(hooksDir, "pre-commit");
@@ -130407,6 +130564,16 @@ function runHooksInstall() {
130407
130564
  fs16.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 493 });
130408
130565
  console.log("TrueCourse pre-commit hook installed.");
130409
130566
  console.log(` ${hookPath}`);
130567
+ const projectRoot = findProjectRoot(process.cwd());
130568
+ if (projectRoot) {
130569
+ const cfgDir = path21.join(projectRoot, ".truecourse");
130570
+ const cfgPath = path21.join(cfgDir, "hooks.yaml");
130571
+ if (!fs16.existsSync(cfgPath)) {
130572
+ fs16.mkdirSync(cfgDir, { recursive: true });
130573
+ fs16.writeFileSync(cfgPath, HOOKS_YAML_TEMPLATE);
130574
+ console.log(` ${cfgPath} (starter config \u2014 edit to customize, commit to share with the team)`);
130575
+ }
130576
+ }
130410
130577
  }
130411
130578
  function runHooksUninstall() {
130412
130579
  const gitDir = findGitDir(process.cwd());
@@ -130444,125 +130611,97 @@ function runHooksStatus() {
130444
130611
  }
130445
130612
  const projectRoot = findProjectRoot(process.cwd());
130446
130613
  if (projectRoot) {
130447
- const configPath = path21.join(projectRoot, ".truecourse", "hooks.yaml");
130448
- if (fs16.existsSync(configPath)) {
130449
- console.log(`
130450
- Config: ${configPath}`);
130451
- const config2 = loadConfig(projectRoot);
130452
- const blockRules = getBlockRules(config2);
130453
- console.log("Block on:");
130454
- for (const rule of blockRules) {
130455
- if (typeof rule === "string") {
130456
- console.log(` - ${rule}`);
130457
- } else {
130458
- console.log(` - severity: ${rule.severity}`);
130459
- }
130460
- }
130461
- const timeoutMs = getTimeoutMs(config2);
130462
- console.log(`Timeout: ${timeoutMs / 1e3}s`);
130614
+ const cfg = loadConfig(projectRoot);
130615
+ if (!cfg) {
130616
+ console.log(
130617
+ "\nNo `.truecourse/hooks.yaml` \u2014 hook has no policy. Run `truecourse hooks install` to generate one."
130618
+ );
130463
130619
  } else {
130464
- console.log("\nNo config file. Using defaults (block on: security/deterministic/hardcoded-secret, severity: critical).");
130465
- }
130466
- }
130467
- }
130468
- function shouldBlock(violation, blockRules) {
130469
- for (const rule of blockRules) {
130470
- if (typeof rule === "string") {
130471
- if (violation.ruleKey === rule || violation.ruleKey.endsWith(`/${rule}`)) {
130472
- return true;
130473
- }
130474
- } else if (rule.severity) {
130475
- if (violation.severity.toLowerCase() === rule.severity.toLowerCase()) {
130476
- return true;
130477
- }
130620
+ console.log(`
130621
+ Config: ${cfg.configPath}`);
130622
+ console.log(`Block on severities: ${cfg.blockOn.join(", ")}`);
130623
+ console.log(`LLM rules on commit: ${cfg.llm ? "enabled (tokens per commit)" : "disabled"}`);
130478
130624
  }
130479
130625
  }
130480
- return false;
130481
130626
  }
130482
130627
  async function runHooksRun() {
130483
- const startTime = Date.now();
130484
130628
  process.stdout.write("TrueCourse pre-commit check...");
130485
130629
  const projectRoot = findProjectRoot(process.cwd());
130486
130630
  if (!projectRoot) {
130487
130631
  console.log(" skipped (not a git repository)");
130488
130632
  process.exit(0);
130489
130633
  }
130490
- const config2 = loadConfig(projectRoot);
130491
- const blockRules = getBlockRules(config2);
130492
- const timeoutMs = getTimeoutMs(config2);
130493
- let stagedFiles;
130634
+ let hasStaged = false;
130494
130635
  try {
130495
130636
  const output = execSync5("git diff --cached --name-only --diff-filter=ACM", {
130496
130637
  encoding: "utf-8",
130497
130638
  cwd: projectRoot
130498
130639
  }).trim();
130499
- stagedFiles = output ? output.split("\n") : [];
130640
+ hasStaged = output.length > 0;
130500
130641
  } catch {
130501
130642
  console.log(" skipped (git error)");
130502
130643
  process.exit(0);
130503
130644
  }
130504
- if (stagedFiles.length === 0) {
130645
+ if (!hasStaged) {
130505
130646
  console.log(" \u2714 passed (no staged files)");
130506
130647
  process.exit(0);
130507
130648
  }
130508
- let parseCode2;
130509
- let detectLanguage2;
130510
- let checkCodeRules2;
130511
- let CODE_RULES2;
130512
- try {
130513
- const analyzer = await Promise.resolve().then(() => (init_dist6(), dist_exports2));
130514
- parseCode2 = analyzer.parseCode;
130515
- detectLanguage2 = analyzer.detectLanguage;
130516
- checkCodeRules2 = analyzer.checkCodeRules;
130517
- CODE_RULES2 = analyzer.CODE_RULES;
130518
- await analyzer.initParsers();
130519
- } catch {
130520
- console.log(" skipped (analyzer not available)");
130649
+ const cfg = loadConfig(projectRoot);
130650
+ if (!cfg) {
130651
+ console.log(" skipped");
130652
+ console.error(
130653
+ "No `.truecourse/hooks.yaml` found. The pre-commit hook has no policy to\nenforce \u2014 run `truecourse hooks install` to generate one."
130654
+ );
130521
130655
  process.exit(0);
130522
130656
  }
130523
- const supportedFiles = stagedFiles.filter((f) => detectLanguage2(f) !== null);
130524
- if (supportedFiles.length === 0) {
130525
- console.log(" \u2714 passed");
130526
- process.exit(0);
130657
+ const repoDir = resolveRepoDir(process.cwd());
130658
+ const project = repoDir ? getProjectByPath(repoDir) ?? registerProject(repoDir) : null;
130659
+ if (!project || !readLatest(project.path)) {
130660
+ console.log("");
130661
+ console.error(
130662
+ "No baseline analysis yet. Run `truecourse analyze` once in this repo before\nthe pre-commit hook can block new violations. Or bypass this commit with\n`git commit --no-verify`."
130663
+ );
130664
+ process.exit(1);
130527
130665
  }
130528
- const allViolations = [];
130529
- for (const filePath of supportedFiles) {
130530
- if (Date.now() - startTime > timeoutMs) {
130531
- console.log("\n Warning: timeout reached, skipping remaining files.");
130532
- break;
130533
- }
130534
- const language = detectLanguage2(filePath);
130535
- if (!language) continue;
130536
- let content;
130537
- try {
130538
- content = execSync5(`git show ":${filePath}"`, {
130539
- encoding: "utf-8",
130540
- cwd: projectRoot,
130541
- maxBuffer: 5 * 1024 * 1024
130542
- // 5MB
130543
- });
130544
- } catch {
130545
- continue;
130546
- }
130547
- try {
130548
- const tree = parseCode2(content, language);
130549
- const violations = checkCodeRules2(tree, filePath, content, CODE_RULES2, language);
130550
- allViolations.push(...violations);
130551
- } catch {
130552
- continue;
130666
+ const abortController = new AbortController();
130667
+ const onSigint = () => abortController.abort();
130668
+ process.on("SIGINT", onSigint);
130669
+ process.stdout.write(" running analysis...");
130670
+ let newViolations;
130671
+ try {
130672
+ const { diff } = await diffInProcess(project, {
130673
+ signal: abortController.signal,
130674
+ enableLlmRulesOverride: cfg.llm,
130675
+ // Pre-approved: the user opted into LLM-in-hook by setting llm: true
130676
+ // in hooks.yaml, so we don't re-prompt for the token cost estimate.
130677
+ onLlmEstimate: async () => true
130678
+ });
130679
+ newViolations = diff.newViolations;
130680
+ } catch (err) {
130681
+ console.log("");
130682
+ if (err instanceof DOMException && err.name === "AbortError") {
130683
+ console.error("Pre-commit check cancelled.");
130684
+ process.exit(130);
130553
130685
  }
130686
+ console.error(`Pre-commit check failed: ${err instanceof Error ? err.message : String(err)}`);
130687
+ process.exit(1);
130688
+ } finally {
130689
+ process.removeListener("SIGINT", onSigint);
130554
130690
  }
130555
- const blocking = allViolations.filter((v) => shouldBlock(v, blockRules));
130691
+ const blockSet = new Set(cfg.blockOn);
130692
+ const blocking = newViolations.filter((v) => blockSet.has(v.severity.toLowerCase()));
130556
130693
  if (blocking.length === 0) {
130557
- console.log(" \u2714 passed");
130694
+ console.log(` \u2714 passed (${newViolations.length} new violations, none at or above ${cfg.blockOn.join("/")})`);
130558
130695
  process.exit(0);
130559
130696
  }
130560
130697
  console.log("\n");
130561
130698
  for (const v of blocking) {
130562
130699
  const icon = severityIcon(v.severity);
130563
130700
  const color = severityColor(v.severity);
130701
+ const location = v.filePath ? `${v.filePath}${v.lineStart ? `:${v.lineStart}` : ""}` : "(no file)";
130564
130702
  console.log(` ${color(`${icon} BLOCKED`)}: ${v.title}`);
130565
- console.log(` ${v.filePath}:${v.lineStart} \u2014 ${v.content}`);
130703
+ console.log(` ${location} \u2014 ${v.content}`);
130704
+ if (v.fixPrompt) console.log(` Fix: ${v.fixPrompt}`);
130566
130705
  console.log("");
130567
130706
  }
130568
130707
  console.log("Commit blocked. Fix the issue or bypass with --no-verify.");
@@ -130571,9 +130710,14 @@ async function runHooksRun() {
130571
130710
 
130572
130711
  // tools/cli/src/index.ts
130573
130712
  var program2 = new Command();
130574
- program2.name("truecourse").version("0.5.0").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
130575
- var dashboardCmd = program2.command("dashboard").description("Start the TrueCourse dashboard and open it in your browser").option("--reconfigure", "Re-prompt for console vs background service mode").action(async (options) => {
130576
- await runDashboard({ reconfigure: options.reconfigure });
130713
+ program2.name("truecourse").version("0.5.2").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
130714
+ var dashboardCmd = program2.command("dashboard").description("Start the TrueCourse dashboard and open it in your browser").option("--reconfigure", "Re-prompt for console vs background service mode").option("--service", "Run as a background service (skips mode prompt)").option("--console", "Run in this terminal (skips mode prompt)").action(async (options) => {
130715
+ if (options.service && options.console) {
130716
+ console.error("error: --service and --console are mutually exclusive");
130717
+ process.exit(1);
130718
+ }
130719
+ const mode = options.service ? "service" : options.console ? "console" : void 0;
130720
+ await runDashboard({ reconfigure: options.reconfigure, mode });
130577
130721
  });
130578
130722
  dashboardCmd.command("stop").description("Stop the dashboard").action(async () => {
130579
130723
  await runDashboardStop();
@@ -130581,29 +130725,40 @@ dashboardCmd.command("stop").description("Stop the dashboard").action(async () =
130581
130725
  dashboardCmd.command("status").description("Show dashboard status").action(async () => {
130582
130726
  await runDashboardStatus();
130583
130727
  });
130584
- dashboardCmd.command("logs").description("Tail dashboard logs (service mode only)").action(() => {
130585
- runDashboardLogs();
130728
+ dashboardCmd.command("logs").description("Tail dashboard logs (service mode only)").action(async () => {
130729
+ await runDashboardLogs();
130586
130730
  });
130587
130731
  dashboardCmd.command("uninstall").description("Remove the background service and revert to console mode").action(async () => {
130588
130732
  await runDashboardUninstall();
130589
130733
  });
130590
- program2.command("analyze").description("Analyze the current repository").option("--diff", "Run diff check against latest analysis").action(async (options) => {
130734
+ function resolveInstallSkills(options) {
130735
+ if (options.installSkills === true) return true;
130736
+ if (options.skills === false) return false;
130737
+ return void 0;
130738
+ }
130739
+ program2.command("analyze").description("Analyze the current repository").option("--diff", "Run diff check against latest analysis").option("--llm", "Run LLM-powered rules (pre-approves the cost estimate)").option("--no-llm", "Skip LLM-powered rules for this run").option("--install-skills", "Install Claude Code skills without prompting").option("--no-skills", "Skip the Claude Code skills prompt").action(async (options) => {
130740
+ const llm = typeof options.llm === "boolean" ? options.llm : void 0;
130741
+ const installSkills = resolveInstallSkills(options);
130591
130742
  if (options.diff) {
130592
- await runAnalyzeDiff();
130743
+ await runAnalyzeDiff({ llm, installSkills });
130593
130744
  } else {
130594
- await runAnalyze();
130745
+ await runAnalyze({ llm, installSkills });
130595
130746
  }
130596
130747
  });
130597
- program2.command("add").description("Register the current directory with TrueCourse").action(async () => {
130598
- await runAdd();
130748
+ program2.command("add").description("Register the current directory with TrueCourse").option("--install-skills", "Install Claude Code skills without prompting").option("--no-skills", "Skip the Claude Code skills prompt").action(async (options) => {
130749
+ await runAdd({ installSkills: resolveInstallSkills(options) });
130599
130750
  });
130600
- program2.command("list").description("List violations from the latest analysis").option("--diff", "Show diff check results (new and resolved)").option("--limit <n>", "Number of violations to show (default: 20)", parseInt).option("--offset <n>", "Skip first N violations", parseInt).option("--all", "Show all violations").action(async (options) => {
130751
+ program2.command("list").description("List violations from the latest analysis").option("--diff", "Show diff check results (new and resolved)").option("--limit <n>", "Number of violations to show (default: 20)", parseInt).option("--offset <n>", "Skip first N violations", parseInt).option("--all", "Show all violations").option(
130752
+ "--severity <list>",
130753
+ "Comma-separated severities to include (critical,high,medium,low,info)"
130754
+ ).action(async (options) => {
130601
130755
  if (options.diff) {
130602
130756
  await runListDiff();
130603
130757
  } else {
130604
130758
  await runList({
130605
130759
  limit: options.all ? Infinity : options.limit ?? 20,
130606
- offset: options.offset ?? 0
130760
+ offset: options.offset ?? 0,
130761
+ severity: parseSeverityFlag(options.severity)
130607
130762
  });
130608
130763
  }
130609
130764
  });
@@ -130634,8 +130789,8 @@ telemetryCmd.command("status").description("Show current telemetry status").acti
130634
130789
  }
130635
130790
  });
130636
130791
  var hooksCmd = program2.command("hooks").description("Manage git hooks");
130637
- hooksCmd.command("install").description("Install pre-commit hook").action(() => {
130638
- runHooksInstall();
130792
+ hooksCmd.command("install").description("Install pre-commit hook").action(async () => {
130793
+ await runHooksInstall();
130639
130794
  });
130640
130795
  hooksCmd.command("uninstall").description("Remove pre-commit hook").action(() => {
130641
130796
  runHooksUninstall();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truecourse",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Visualize your codebase architecture as an interactive graph",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,10 @@
21
21
  "node-windows": "^1.0.0-beta.8"
22
22
  },
23
23
  "license": "MIT",
24
+ "author": {
25
+ "name": "Mushegh Gevorgyan",
26
+ "email": "mushegh@truecourse.dev"
27
+ },
24
28
  "repository": {
25
29
  "type": "git",
26
30
  "url": "https://github.com/truecourse-ai/truecourse"
package/server.mjs CHANGED
@@ -152216,6 +152216,10 @@ function listViolations(repoPath, options = {}) {
152216
152216
  (v) => v.type === "code" && (v.filePath === absPath || v.filePath === options.filePath)
152217
152217
  );
152218
152218
  }
152219
+ if (options.severity !== void 0) {
152220
+ const allowed = (Array.isArray(options.severity) ? options.severity : [options.severity]).map((s) => s.toLowerCase());
152221
+ filtered = filtered.filter((v) => allowed.includes(v.severity.toLowerCase()));
152222
+ }
152219
152223
  filtered.sort((a, b) => {
152220
152224
  const sa = SEVERITY_ORDER2[a.severity] ?? 5;
152221
152225
  const sb = SEVERITY_ORDER2[b.severity] ?? 5;
@@ -153596,10 +153600,15 @@ router5.get(
153596
153600
  const offsetParam = parseInt(req.query.offset) || 0;
153597
153601
  const statusParam = req.query.status;
153598
153602
  const status = statusParam === "resolved" ? "resolved" : statusParam === "all" ? "all" : "active";
153603
+ const severityParam = req.query.severity;
153604
+ const severity = severityParam ? severityParam.split(",").map((s) => s.trim().toLowerCase()).filter(
153605
+ (s) => ["critical", "high", "medium", "low", "info"].includes(s)
153606
+ ) : void 0;
153599
153607
  const { violations, total } = listViolations(repo.path, {
153600
153608
  analysisId: req.query.analysisId,
153601
153609
  filePath: req.query.file,
153602
153610
  status,
153611
+ severity: severity && severity.length > 0 ? severity : void 0,
153603
153612
  limit: limitParam,
153604
153613
  offset: offsetParam
153605
153614
  });
@@ -15,25 +15,42 @@ Run architecture analysis on the current repository using TrueCourse.
15
15
 
16
16
  ## Important
17
17
 
18
- - The TrueCourse server must be running before using this skill. If it's not running, tell the user to start it with `npx truecourse start` and try again.
19
- - Full analysis stashes any uncommitted changes, analyzes the clean working tree, then unstashes. The user's uncommitted work is preserved.
20
- - Diff check analyzes only files changed since the last analysis it does NOT stash.
18
+ - **Full analysis** stashes any uncommitted changes, analyzes the clean working tree, then unstashes. The user's uncommitted work is preserved.
19
+ - **Diff check** analyzes the full working tree (including uncommitted changes it does NOT stash) and compares the result against the last full analysis baseline. The report lists violations newly introduced and violations resolved since that baseline. Prefer diff for in-progress work where the user is iterating on changes.
20
+ - **Always invoke via `npx -y`** without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version (which happens every time we publish a new release).
21
+ - **LLM rules cost tokens.** Never pass `--llm` without first relaying the token estimate to the user and getting approval. See the LLM flow below.
21
22
 
22
23
  ## Instructions
23
24
 
24
- 1. Ask the user whether they want a **full analysis** or a **diff check** (changes only). If they mentioned "diff" in their request, default to diff mode.
25
+ ### 1. Pick mode
26
+ Ask the user whether they want a **full analysis** or a **diff check**. If they said "diff" in their request, default to diff.
25
27
 
26
- 2. Run the appropriate command using the Bash tool:
27
- - **Full analysis:** `npx truecourse analyze --no-autostart`
28
- - **Diff check:** `npx truecourse analyze --diff --no-autostart`
28
+ - Full: `npx -y truecourse analyze`
29
+ - Diff: `npx -y truecourse analyze --diff`
29
30
 
30
- 3. This is a long-running command (can take several minutes). Let it run to completion — do NOT set a short timeout. Use a timeout of at least 600000ms (10 minutes).
31
+ ### 2. Decide on LLM rules
31
32
 
32
- 4. If the command fails with "Could not connect to TrueCourse server", tell the user to run `npx truecourse start` first.
33
+ LLM rules add higher-value insights but cost tokens per run. Ask the user one question: **"Run LLM-powered rules this time?"** If the user is unsure, offer to run deterministic-only first (no tokens, fast) and add LLM later.
33
34
 
34
- 5. When the command finishes, summarize the output for the user:
35
- - Number of violations found (by severity)
36
- - Number of changed files (for diff mode)
37
- - Any errors that occurred
35
+ - If **user approves LLM**: append `--llm` to the command.
36
+ - If **user declines LLM or wants a free run**: append `--no-llm`.
38
37
 
39
- 6. After summarizing, tell the user they can run `/truecourse-list` to see the full violation details, or `/truecourse-fix` to apply suggested fixes.
38
+ You MUST pass either `--llm` or `--no-llm` running without either in a non-interactive shell will exit with an error naming the flags.
39
+
40
+ ### 3. Run the command
41
+
42
+ Use the Bash tool. This is long-running (minutes, especially with `--llm`) — use a timeout of at least 600000ms (10 minutes).
43
+
44
+ ### 4. Summarize
45
+
46
+ When the command finishes, read the printed summary and relay the key numbers:
47
+
48
+ - **Full analyze**: one line with the total violation count and per-severity breakdown, e.g. `15 violations (2 critical, 5 high, 8 medium)`.
49
+ - **Diff analyze**: `Changed files: N (X modified, Y new, Z deleted)` and `Summary: N new issues, N resolved`. If you see `⚠ Results may be stale — baseline analysis has changed.`, surface that warning to the user and suggest running a full `npx -y truecourse analyze` to refresh the baseline.
50
+ - If the command errored, relay the error message.
51
+
52
+ ### 5. Next steps
53
+
54
+ Tell the user they can:
55
+ - Run `/truecourse-list` to see the full violation list.
56
+ - Run `/truecourse-fix` to apply suggested fixes.
@@ -6,29 +6,55 @@ triggers:
6
6
  - fix violations
7
7
  - apply fixes
8
8
  - fix my code
9
+ - fix diff violations
9
10
  ---
10
11
 
11
12
  # TrueCourse Fix
12
13
 
13
14
  Apply fixes for TrueCourse violations that have fix suggestions.
14
15
 
16
+ ## Important
17
+
18
+ - **Always invoke via `npx -y`** — without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version.
19
+ - **Default to the diff flow.** Users usually want to fix the violations introduced by the changes they're currently iterating on — not the whole repo. Start by asking which set to fix.
20
+ - **Only violations with a `Fix:` block can be auto-fixed.** Violations without one require human design decisions; mention them but don't attempt them.
21
+
15
22
  ## Instructions
16
23
 
17
- 1. First, fetch the current violations by running:
18
- ```
19
- npx truecourse list
20
- ```
24
+ ### 1. Pick the violation set
25
+
26
+ Ask: *"Do you want to fix violations from the latest full analysis, or just the changes you're working on right now (diff)?"*
27
+
28
+ - **Diff mode** (recommended default): `npx -y truecourse list --diff` — small set, usually fine to load in one call.
29
+ - **Full mode**: start with a paged view, `npx -y truecourse list` (first 20). If the summary line shows a large total and the user hasn't narrowed scope, ask them to narrow before pulling more pages:
30
+ - by severity — use `--severity <list>`, e.g. `npx -y truecourse list --severity critical,high`. Valid values: `critical,high,medium,low,info`.
31
+ - by a specific file / module / service they care about — no server-side filter yet, so page through and keep only matches.
32
+
33
+ Avoid `--all` on large repos — it dumps every violation into context even though most won't be fixable.
34
+
35
+ If `list --diff` returns "no diff results yet" or "stale diff", suggest the user first run `/truecourse-analyze` in diff mode.
36
+
37
+ ### 2. Identify fixable violations
38
+
39
+ From the output in hand, keep only violations that contain a `Fix:` block — those are the ones with actionable fix suggestions. If you loaded only one page in full mode, tell the user how many were fixable on this page and offer to continue to the next page (`--offset 20`, `--offset 40`, …) if they want more.
40
+
41
+ If none on the loaded page(s) are fixable, tell the user and stop (or offer to page further).
42
+
43
+ ### 3. Present and select
44
+
45
+ Show the fixable violations as a numbered list with title, severity, and target location. Ask which ones to fix (they can pick numbers or say "all").
21
46
 
22
- 2. Parse the output to identify violations that have a **Fix:** suggestion. These are the only violations that can be automatically fixed.
47
+ ### 4. Apply
23
48
 
24
- 3. If no violations have fix suggestions, tell the user and stop.
49
+ For each selected violation:
50
+ - Read the `Fix:` text.
51
+ - Use the Read tool to load the relevant source file(s).
52
+ - Use the Edit tool to apply the change.
53
+ - Briefly describe what you changed.
25
54
 
26
- 4. Present the fixable violations to the user as a numbered list with their title, severity, and target location. Ask which ones they'd like to fix (they can pick specific numbers or say "all").
55
+ ### 5. Re-verify
27
56
 
28
- 5. For each selected violation, read the fix suggestion and apply it to the codebase:
29
- - The fix suggestion describes what code change to make
30
- - Use the Read tool to read the relevant source file(s)
31
- - Use the Edit tool to apply the fix
32
- - Explain what you changed
57
+ After fixes, suggest the user re-run the appropriate analysis to confirm the violations are resolved:
33
58
 
34
- 6. After applying fixes, suggest the user run `/truecourse-analyze` again to verify the fixes resolved the violations.
59
+ - If you worked in **diff mode**: run `npx -y truecourse analyze --diff --no-llm` (fast, free). If they want LLM rules re-checked too, use `--llm` and relay the cost estimate first.
60
+ - If you worked in **full mode**: suggest `/truecourse-analyze` so the user picks the LLM/no-LLM decision fresh.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: truecourse-hooks
3
+ description: Install, configure, or remove the TrueCourse pre-commit hook
4
+ user_invocable: true
5
+ triggers:
6
+ - install the pre-commit hook
7
+ - set up truecourse hooks
8
+ - enable truecourse hook
9
+ - check hook status
10
+ - remove pre-commit hook
11
+ - change what the hook blocks
12
+ - edit hook config
13
+ ---
14
+
15
+ # TrueCourse Hooks
16
+
17
+ Install, configure, and manage the pre-commit hook that blocks new violations before they land in git.
18
+
19
+ ## Important
20
+
21
+ - **Always invoke via `npx -y`** — without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version.
22
+ - **The hook makes commits slower.** Every commit runs `truecourse analyze --diff`. On large repos that can be tens of seconds per commit. Make sure the user knows before you install.
23
+ - **Baseline required.** The hook needs a `truecourse analyze` to have run at least once in the repo — otherwise every commit is blocked with "run analyze first". If the user hasn't, suggest running `/truecourse-analyze` first (or `npx -y truecourse analyze`).
24
+ - **`hooks.yaml` is the single source of truth.** Installation creates `<repo>/.truecourse/hooks.yaml` with defaults; edit it to change policy. The file is meant to be committed so the whole team shares one hook config.
25
+
26
+ ## Instructions
27
+
28
+ ### 1. Figure out what the user wants
29
+
30
+ - "install", "set up", "enable" → **Install flow**
31
+ - "status", "is the hook active", "what does it block" → **Status flow**
32
+ - "uninstall", "remove", "disable" → **Uninstall flow**
33
+ - "change what blocks", "make it stricter/looser", "add/remove severities", "enable LLM" → **Configure flow**
34
+
35
+ ### 2. Install flow
36
+
37
+ 1. Tell the user the tradeoff upfront: commits will be slower; this repo needs a `truecourse analyze` baseline; policy lives in `.truecourse/hooks.yaml` which they should commit.
38
+ 2. Run:
39
+ ```
40
+ npx -y truecourse hooks install
41
+ ```
42
+ 3. Relay the output. Two files get created:
43
+ - `.git/hooks/pre-commit` (the script git invokes)
44
+ - `.truecourse/hooks.yaml` (starter policy, blocks `critical` and `high` by default, LLM off)
45
+ 4. If the user hasn't run a full analysis in this repo, suggest `/truecourse-analyze` — without it, every commit will be blocked with "no baseline" until they do.
46
+
47
+ ### 3. Status flow
48
+
49
+ Run:
50
+ ```
51
+ npx -y truecourse hooks status
52
+ ```
53
+ Relay the output. It reports whether the hook is installed, the config path, the block severities, and whether LLM is on.
54
+
55
+ ### 4. Uninstall flow
56
+
57
+ Run:
58
+ ```
59
+ npx -y truecourse hooks uninstall
60
+ ```
61
+ Only removes the git hook script. `hooks.yaml` is preserved (it's team policy, not install state).
62
+
63
+ ### 5. Configure flow
64
+
65
+ The config lives at `<repo>/.truecourse/hooks.yaml`. Use the Read and Edit tools — do not shell out through `truecourse` for edits.
66
+
67
+ Schema:
68
+ ```yaml
69
+ pre-commit:
70
+ block-on: [critical, high] # valid: info, low, medium, high, critical
71
+ llm: false # true = LLM rules on every commit (tokens per commit)
72
+ ```
73
+
74
+ Common edits the user might ask for:
75
+ - **Stricter** ("block medium too"): `block-on: [critical, high, medium]`
76
+ - **Permissive** ("only block criticals"): `block-on: [critical]`
77
+ - **Enable LLM** ("run full checks on commit"): set `llm: true`. Warn the user this spends tokens on every commit — confirm before flipping it.
78
+
79
+ After editing, run `npx -y truecourse hooks status` so they can verify the parsed values match their intent.
80
+
81
+ ### 6. When the user hits a blocked commit
82
+
83
+ If a user comes to you saying "my commit got blocked" or similar:
84
+ - The hook's stdout already listed the blocking violations (file, line, title, severity).
85
+ - Offer to run `/truecourse-fix` to apply fix suggestions to those violations.
86
+ - If they want to ship anyway, remind them of `git commit --no-verify` (standard git bypass).
@@ -13,14 +13,29 @@ triggers:
13
13
 
14
14
  Show violations and analysis results from TrueCourse.
15
15
 
16
+ ## Important
17
+
18
+ - **Always invoke via `npx -y`** — without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version.
19
+ - **Don't dump everything at once.** Plain `list` shows the first 20 violations — use that by default. Large repos can have hundreds; pasting them all wastes context and the user can't read that much in one go. Page or filter instead.
20
+
16
21
  ## Instructions
17
22
 
18
- 1. Determine whether to show **full violations** or **diff results**. If the user mentioned "diff" in their request, use diff mode.
23
+ ### 1. Pick mode
24
+ Determine whether the user wants **full violations** (from the last full analysis) or **diff results** (changes since the last full analysis). If they said "diff" in their request, use diff mode.
25
+
26
+ - Full: `npx -y truecourse list`
27
+ - Diff: `npx -y truecourse list --diff`
28
+
29
+ ### 2. Run and present
30
+
31
+ Use the Bash tool. The output is already formatted — show it as-is.
19
32
 
20
- 2. Run the appropriate command using the Bash tool:
21
- - **Full violations:** `npx truecourse list`
22
- - **Diff results:** `npx truecourse list --diff`
33
+ The final line summarises totals, e.g. `Showing 1–20 of 287 violations (12 critical, 45 high, ...)`. Lead your reply with that total + severity breakdown so the user knows the scope before scanning the first page.
23
34
 
24
- 3. Present the output to the user. The command output is already formatted — show it as-is.
35
+ ### 3. Offer the next step
25
36
 
26
- 4. If violations with fix suggestions are found, tell the user they can run `/truecourse-fix` to apply fixes.
37
+ After showing the first page, ask the user what they want:
38
+ - **Filter by severity** — `--severity <list>`, e.g. `--severity critical,high`. Valid values: `critical,high,medium,low,info`.
39
+ - **Page further** — use `--offset <n>` (next page is `--offset 20`, then `--offset 40`, …) or widen with `--limit <n>`.
40
+ - **Everything in one view** — `--all` (only when the user explicitly asks for the full dump, e.g. "show all of them", "give me the whole list"). Avoid by default.
41
+ - **Start fixing** — `/truecourse-fix` (only for violations with a `Fix:` block attached).