sourcecode 3.2.1__py3-none-any.whl → 3.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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

sourcecode/cli.py CHANGED
@@ -149,6 +149,88 @@ def _check_pipeline_coherence(sm: "SourceMap") -> list[str]: # type: ignore[nam
149
149
 
150
150
  return issues
151
151
 
152
+ # ---------------------------------------------------------------------------
153
+ # Command tiers — what we promise about a command, published where it is read
154
+ # ---------------------------------------------------------------------------
155
+
156
+ #: The support commitment behind every command — one authority for it, read by
157
+ #: `--help`, by the README command table and by the user guide.
158
+ #:
159
+ #: A tier is a **stability** promise, never a value ranking. `posture` is the most
160
+ #: differentiated capability in the product *and* it is experimental: its shape can
161
+ #: still change under a minor, so nobody should gate a pipeline on it yet. Those are
162
+ #: two facts, and collapsing them onto one axis is how a surface starts lying — the
163
+ #: first panel of `--help` still says "start here" for the commands worth learning
164
+ #: first, and this table says what each command's output is worth relying on.
165
+ #:
166
+ #: This is not the pricing tier: Free/Pro is a separate axis (docs/PRODUCT_TIERS.md)
167
+ #: and gates repository size, never capability.
168
+ #:
169
+ #: Assigning the tiers is a later milestone's work (commands moving between them,
170
+ #: `parked` disappearing from the default help). Naming them is what ships here, so
171
+ #: the vocabulary exists before it is used against a command.
172
+ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
173
+ ("core", "contract stable within a major — safe to gate CI on", (
174
+ "endpoints", "spring-audit", "migrate-check",
175
+ "impact", "impact-chain", "pr-impact", "verify",
176
+ )),
177
+ ("supported", "maintained; fields are added, never removed without a major", (
178
+ "verify-edit", "review-pr", "plan", "compare", "delta", "contract-diff",
179
+ "fix-bug", "rename-class", "prepare-context", "onboard", "explain",
180
+ "export", "repo-ir", "validation", "modernize", "chunk-file",
181
+ "cold-start", "baseline", "activate", "config", "schema", "version",
182
+ "cache", "auth", "mcp", "telemetry",
183
+ )),
184
+ ("experimental", "shape may change in a minor — do not gate CI on it", (
185
+ "posture", "archetype", "retrieve",
186
+ )),
187
+ ("parked", "kept working, no longer developed", ()),
188
+ )
189
+
190
+ #: Tiers whose membership is short enough to name in `--help`. `supported` is the
191
+ #: remainder by construction, and printing twenty-six names would bury the two lists
192
+ #: a reader acts on.
193
+ _TIERS_NAMED_IN_HELP = ("core", "experimental", "parked")
194
+
195
+
196
+ def command_tier(name: str) -> "str | None":
197
+ """The tier of a registered command, or None when it is in no tier.
198
+
199
+ None is a defect, not a state: the battery fails on it. It is returned rather
200
+ than defaulted so an unassigned command can never be silently published as
201
+ `supported`.
202
+ """
203
+ for tier, _promise, members in COMMAND_TIERS:
204
+ if name in members:
205
+ return tier
206
+ return None
207
+
208
+
209
+ def _tier_help_block() -> str:
210
+ """The tier table as `--help` prints it — generated from `COMMAND_TIERS`, because
211
+ a hand-written catalogue beside a generated one drifts (it is what left
212
+ `endpoints` unlisted while it shipped)."""
213
+ import textwrap
214
+
215
+ lines = ["[bold]Command tiers[/bold] [dim](a stability promise, not a value ranking):[/dim]"]
216
+ indent = " " * 4
217
+ for tier, promise, members in COMMAND_TIERS:
218
+ if tier not in _TIERS_NAMED_IN_HELP:
219
+ listed = "every other command in the panels below"
220
+ elif members:
221
+ listed = " · ".join(members)
222
+ else:
223
+ listed = "none today"
224
+ lines.append(f" [bold]{tier:<14}[/bold]{promise}")
225
+ # Pre-wrapped so the renderer never breaks a command name at its hyphen.
226
+ wrapped = textwrap.fill(
227
+ listed, width=70, initial_indent=indent, subsequent_indent=indent,
228
+ break_on_hyphens=False, break_long_words=False,
229
+ )
230
+ lines.append(f"[dim]{wrapped}[/dim]")
231
+ return "\n".join(lines)
232
+
233
+
152
234
  def _build_help_text() -> str:
153
235
  """Build --help text dynamically based on current license state."""
154
236
  try:
@@ -193,6 +275,8 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
193
275
 
194
276
  [dim]Every command is grouped in the panels below; full reference in docs/USER_GUIDE.md[/dim]
195
277
 
278
+ {_tier_help_block()}
279
+
196
280
  [bold]Auth commands:[/bold]
197
281
  auth status [dim]# show current plan and auth state[/dim]
198
282
  auth logout [dim]# remove local credentials[/dim]
@@ -829,7 +913,7 @@ try:
829
913
  except Exception:
830
914
  pass
831
915
 
832
- telemetry_app = typer.Typer(help="Manage anonymous telemetry (on by default; opt-out).", rich_markup_mode="rich")
916
+ telemetry_app = typer.Typer(help="Manage anonymous telemetry (off by default; opt-in).", rich_markup_mode="rich")
833
917
  app.add_typer(telemetry_app, name="telemetry")
834
918
 
835
919
  mcp_app = typer.Typer(help="MCP integration: setup, status, serve, remove.", rich_markup_mode="rich")
@@ -857,8 +941,9 @@ app.add_typer(retrieve_app, name="retrieve")
857
941
  def _maybe_show_telemetry_notice() -> None:
858
942
  """Show first-run telemetry notice once, on interactive TTYs only.
859
943
 
860
- Telemetry is on by default (opt-out). We inform rather than ask, then
861
- mark the notice as shown so it appears only once.
944
+ Telemetry is off by default (opt-in). The notice is an invitation, not a
945
+ disclosure: nothing has been collected when it appears. Marked as shown so it
946
+ appears only once.
862
947
  """
863
948
  try:
864
949
  from sourcecode.telemetry.config import has_been_asked, mark_asked
@@ -1699,12 +1784,21 @@ def main(
1699
1784
  f"ex={_excl_key},depth={effective_depth}"
1700
1785
  )
1701
1786
  _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
1702
- if _git_sha and _git_root_str:
1703
- _core_key = f"{_git_sha}-{_core_h}"
1787
+ # Freshness comes from ONE authority (cache.worktree_signature): the exact
1788
+ # tree state an analysis would read, not the committed HEAD. Keyed on HEAD,
1789
+ # this cache answered for a tree it had not read — an uncommitted pom.xml
1790
+ # gaining a dependency was served the pre-edit analysis with is_stale:false.
1791
+ # Clean tree → the sha, so the ordinary repeat run still hits.
1792
+ _tree_sig = _cache_mod.worktree_signature(
1793
+ Path(_git_root_str) if _git_root_str else target,
1794
+ scope=target,
1795
+ )
1796
+ if _tree_sig:
1797
+ _core_key = f"{_tree_sig}-{_core_h}"
1704
1798
  else:
1705
- # No git history (untracked/no-commit repo) — stable synthetic key
1706
- # scoped per repo path via cache_dir(); invalidated by --no-cache or cache clear.
1707
- _core_key = f"nogit-{_core_h}"
1799
+ # The tree cannot be described (no git, unreadable) — a stable synthetic
1800
+ # key would answer forever from the first run, so skip the cache instead.
1801
+ _core_key = ""
1708
1802
 
1709
1803
  # ── View flags: output presentation only (no re-analysis needed) ──
1710
1804
  _view_flags_str = (
@@ -1719,7 +1813,7 @@ def main(
1719
1813
 
1720
1814
  # ── Lookup ──────────────────────────────────────────────────────
1721
1815
  # Step 1: try L1 to obtain the core_hash needed for L2 key
1722
- _l1_result = _cache_mod.read_core(target, _core_key)
1816
+ _l1_result = _cache_mod.read_core(target, _core_key) if _core_key else None
1723
1817
 
1724
1818
  # Additive overlays (--env-map / --git-context) miss L1 because they sit in
1725
1819
  # the core key, yet neither changes the semantic core — env walks config
@@ -1735,8 +1829,8 @@ def main(
1735
1829
  # we inject exactly the overlays we flipped to land the hit.
1736
1830
  _l1_needs_env_inject = False
1737
1831
  _l1_needs_git_inject = False
1738
- if _l1_result is None and (env_map or git_context):
1739
- _sha_prefix = _git_sha if _git_sha else "nogit"
1832
+ if _l1_result is None and _core_key and (env_map or git_context):
1833
+ _sha_prefix = _tree_sig
1740
1834
  _flippable = []
1741
1835
  if git_context:
1742
1836
  _flippable.append("gc") # inject git is cheap + additive
@@ -3292,10 +3386,11 @@ def prepare_context_cmd(
3292
3386
  from dataclasses import asdict
3293
3387
  import time as _time
3294
3388
 
3295
- # Task-level cache: keyed on (task, git_head, symptom) so warm calls complete in <1s.
3389
+ # Task-level cache: keyed on (task, tree state, symptom) so warm calls complete in <1s.
3390
+ # The tree state comes from the same authority as every other layer, so an
3391
+ # uncommitted edit invalidates here exactly as it does for the root command.
3296
3392
  # Skip for diff-dependent tasks (delta, review-pr), fast mode, and llm_prompt
3297
3393
  # (those embed per-call content that must not be served from cache).
3298
- import subprocess as _pctx_sub
3299
3394
  import hashlib as _pctx_hash
3300
3395
  from sourcecode import cache as _pctx_cache
3301
3396
  _pctx_git_sha = ""
@@ -3303,15 +3398,19 @@ def prepare_context_cmd(
3303
3398
  _pctx_cacheable = task not in ("delta", "review-pr") and not fast and not llm_prompt
3304
3399
  if _pctx_cacheable:
3305
3400
  try:
3306
- _sha_r2 = _pctx_sub.run(
3307
- ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
3308
- capture_output=True, text=True, timeout=3,
3401
+ _pctx_git_sha = _pctx_cache.worktree_signature(
3402
+ _resolve_repo_root(target), scope=target
3309
3403
  )
3310
- _pctx_git_sha = _sha_r2.stdout.strip()
3311
3404
  except Exception:
3312
3405
  pass
3313
3406
  if _pctx_git_sha:
3314
- _sym_h = _pctx_hash.sha256((symptom or "").encode()).hexdigest()[:8]
3407
+ # Every option that changes the answer belongs in the key. --all and
3408
+ # --include-config did not, and went unnoticed only because the key
3409
+ # required a git sha: in a non-git tree nothing was cached at all, so
3410
+ # the collision could not surface.
3411
+ _sym_h = _pctx_hash.sha256(
3412
+ f"sym={symptom or ''};all={all_gaps};cfg={include_config}".encode()
3413
+ ).hexdigest()[:8]
3315
3414
  _pctx_cache_key = f"pctx-{task}-{_pctx_git_sha}-{_sym_h}-{format or 'json'}"
3316
3415
  _cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
3317
3416
  if _cached_pctx is not None:
@@ -3743,16 +3842,22 @@ def prepare_context_cmd(
3743
3842
  @telemetry_app.command("status")
3744
3843
  def telemetry_status() -> None:
3745
3844
  """Show current telemetry setting."""
3746
- from sourcecode.telemetry.config import config_file_path, has_been_asked, is_enabled
3845
+ from sourcecode.telemetry.config import config_file_path, is_enabled, stored_choice
3747
3846
  enabled = is_enabled()
3748
- asked = has_been_asked()
3847
+ choice = stored_choice()
3749
3848
  status = "enabled" if enabled else "disabled"
3750
- typer.echo(f"Telemetry: {status} (on by default; opt-out)")
3751
- if not asked:
3752
- typer.echo(" (first-run notice not yet shown — will show on next run)")
3849
+ typer.echo(f"Telemetry: {status} (off by default; opt-in)")
3850
+ # "off because you said so" and "off because nobody asked you" are different
3851
+ # answers, and a buyer auditing this needs to be told which one they have.
3852
+ if choice is None:
3853
+ typer.echo(" No choice recorded — nothing has been collected or sent.")
3854
+ else:
3855
+ typer.echo(f" Your recorded choice: {'enabled' if choice else 'disabled'}.")
3753
3856
  typer.echo(f" Config: {config_file_path()}")
3754
- typer.echo(" Disable: ask telemetry disable")
3755
- typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0 (or DO_NOT_TRACK=1)")
3857
+ if enabled:
3858
+ typer.echo(" Disable: ask telemetry disable (or SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1)")
3859
+ else:
3860
+ typer.echo(" Enable: ask telemetry enable (or SOURCECODE_TELEMETRY=1)")
3756
3861
 
3757
3862
 
3758
3863
  @telemetry_app.command("enable")
@@ -3774,7 +3879,7 @@ def telemetry_disable() -> None:
3774
3879
  from sourcecode.telemetry.config import set_enabled
3775
3880
  set_enabled(False)
3776
3881
  typer.echo("Telemetry disabled. No data will be collected or sent.")
3777
- typer.echo("Telemetry is on by default; this opt-out is remembered.")
3882
+ typer.echo("Telemetry is off by default; this choice is recorded so the notice stops asking.")
3778
3883
  typer.echo("Re-enable at any time: ask telemetry enable")
3779
3884
 
3780
3885
 
@@ -8000,47 +8105,44 @@ def auth_status_cmd() -> None:
8000
8105
  """Show current authentication and plan status."""
8001
8106
  import json as _json
8002
8107
  try:
8003
- from sourcecode.license import (
8004
- _PRO_UNLOCK_ALL as _unlock,
8005
- _license_data as _ld,
8006
- is_pro as _ip,
8007
- )
8108
+ from sourcecode.license import _license_data as _ld, entitlement as _entitlement
8109
+ _ent = _entitlement()
8008
8110
  except Exception:
8009
8111
  _ld = None
8010
- _ip = False
8011
- _unlock = False
8012
-
8013
- # While the early-adoption unlock is on, every Pro command runs. Reporting
8014
- # `pro: false` here contradicted the --help header and the commands' own
8015
- # behaviour: the status has to describe the gating that is actually in force,
8016
- # not the credential that happens to be absent.
8017
- _gating = {
8018
- "pro_gating": "disabled" if _unlock else "enabled",
8019
- "pro_effective": bool(_ip or _unlock),
8020
- }
8021
- if _unlock:
8022
- _gating["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
8023
-
8024
- if not _ld:
8025
- out: dict = {
8026
- "status": "unauthenticated",
8027
- "pro": bool(_unlock),
8028
- **_gating,
8112
+ _ent = {
8113
+ "entitlement": "free", "source": "free_tier", "authenticated": False,
8114
+ "paywall_active": True, "when_it_changes": "", "free_repo_java_file_limit": None,
8029
8115
  }
8030
- sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
8031
- sys.stdout.flush()
8032
- return
8033
8116
 
8034
- out = {
8035
- "status": "authenticated",
8036
- "auth_method": _ld.get("auth_method", "license_key"),
8037
- "email": _ld.get("email", ""),
8038
- "plan": _ld.get("plan", "unknown"),
8039
- "plan_status": _ld.get("status", "unknown"),
8040
- "pro": _ip,
8041
- "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
8042
- **_gating,
8117
+ # P-2: one authority (`license.entitlement`) answers "what runs here, and why".
8118
+ # The legacy keys below are *derived* from it rather than computed a second
8119
+ # time — that is what stops `{"status": "unauthenticated", "pro": true}` from
8120
+ # reappearing. They are deprecated and will be removed in a future major.
8121
+ _pro = _ent["entitlement"] == "pro"
8122
+ _legacy = {
8123
+ "pro": _pro,
8124
+ "pro_effective": _pro,
8125
+ "pro_gating": "enabled" if _ent["paywall_active"] else "disabled",
8126
+ }
8127
+ if _ent["source"] == "early_adoption_unlock":
8128
+ _legacy["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
8129
+
8130
+ out: dict = {
8131
+ "status": "authenticated" if _ent["authenticated"] else "unauthenticated",
8132
+ **_ent,
8133
+ **_legacy,
8134
+ "deprecated_fields": ["pro", "pro_effective", "pro_gating", "pro_reason"],
8043
8135
  }
8136
+ if _ld:
8137
+ out.update({
8138
+ "auth_method": _ld.get("auth_method", "license_key"),
8139
+ "email": _ld.get("email", ""),
8140
+ "plan": _ld.get("plan", "unknown"),
8141
+ # The authority reads a missing status as active, so reporting
8142
+ # "unknown" here would contradict the entitlement in the same object.
8143
+ "plan_status": _ld.get("status", "active"),
8144
+ "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
8145
+ })
8044
8146
  sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
8045
8147
  sys.stdout.flush()
8046
8148
 
@@ -8172,7 +8274,7 @@ def config_cmd() -> None:
8172
8274
  from sourcecode.telemetry.config import config_file_path, is_enabled
8173
8275
  typer.echo(f"ask {__version__}")
8174
8276
  typer.echo(f"Config: {config_file_path()}")
8175
- typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'}")
8277
+ typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'} (off by default; opt-in)")
8176
8278
  typer.echo("")
8177
8279
  typer.echo("Manage telemetry:")
8178
8280
  typer.echo(" ask telemetry enable")
@@ -9300,17 +9402,32 @@ def cache_warm_cmd(
9300
9402
  ) -> None:
9301
9403
  """Pre-populate the cache by running a fresh analysis.
9302
9404
 
9303
- Runs a full analysis to populate L1/L2 caches and rebuild the RIS
9304
- (Repository Intelligence Snapshot). Useful after a merge/pull in CI.
9405
+ Runs a full analysis to populate the snapshot cache, rebuild the RIS and build
9406
+ the shared Canonical IR. Useful after a merge/pull in CI.
9407
+
9408
+ \b
9409
+ It is not a general warm, and it says so when it finishes: it warms the
9410
+ compact view (add --agent for the agent view), and analysis flags that change
9411
+ what is analysed — --env-map, --depth N, --exclude — rescan anyway. Run
9412
+ `ask cache model` for what a warm gives each command.
9413
+
9414
+ \b
9415
+ In CI without a persisted cache directory, every pipeline pays the cold cost
9416
+ this command reports. Cache ~/.sourcecode/cache and ~/.sourcecode between jobs,
9417
+ or budget the cold run explicitly.
9305
9418
  """
9306
9419
  import shutil as _shutil
9307
9420
  import subprocess as _sub
9421
+ import time as _warm_time
9308
9422
  # Warm exactly the path given. Resolving up to the enclosing git root warmed
9309
9423
  # (and reported on) the whole monorepo when asked for one module — every other
9310
9424
  # command scopes to the argument, so `cache warm ./service-a` populated a cache
9311
9425
  # for a different target than `ask ./service-a` reads.
9312
9426
  target = Path(path).resolve()
9313
9427
  _git_root = _resolve_repo_root(Path(path))
9428
+ # Cost is reported for the whole warm, the CIR build included — that is what a
9429
+ # pipeline pays, and quoting only the analysis half would understate it (C4-5).
9430
+ _warm_t0 = _warm_time.monotonic()
9314
9431
  typer.echo(f"Warming cache for {target} …", err=True)
9315
9432
  if _git_root != target:
9316
9433
  typer.echo(
@@ -9345,6 +9462,58 @@ def cache_warm_cmd(
9345
9462
  err=True,
9346
9463
  )
9347
9464
 
9465
+ # A warm that reports only what it built lets the reader assume it built
9466
+ # everything — which is how "el warm no es un warm general" became a field
9467
+ # finding (C4-3) rather than a documented limit. State both halves, and state
9468
+ # the cost, because in CI it is paid per pipeline (C4-5).
9469
+ _warm_elapsed = _warm_time.monotonic() - _warm_t0
9470
+ _warmed_view = "compact + agent views" if (compact and agent) else (
9471
+ "agent view" if agent else "compact view"
9472
+ )
9473
+ typer.echo(
9474
+ f"Warmed in {_warm_elapsed:.0f}s: {_warmed_view}, RIS, shared CIR, parse cache.",
9475
+ err=True,
9476
+ )
9477
+ typer.echo(
9478
+ "NOT warmed: prepare-context task answers (refactor / fix-bug / generate-tests / "
9479
+ "delta / review-pr), and any run whose analysis flags differ (--env-map, --depth N, "
9480
+ "--exclude) — those rescan. Measured: `endpoints` and `migrate-check` gain nothing "
9481
+ "from a warm. `ask cache model` says what a warm gives each command.",
9482
+ err=True,
9483
+ )
9484
+ typer.echo(
9485
+ f"In CI without a persisted ~/.sourcecode, every pipeline pays this {_warm_elapsed:.0f}s again.",
9486
+ err=True,
9487
+ )
9488
+
9489
+
9490
+ @cache_app.command("model")
9491
+ def cache_model_cmd(
9492
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
9493
+ markdown: bool = typer.Option(False, "--markdown", help="Output the tables published in the user guide."),
9494
+ ) -> None:
9495
+ """What each cache layer stores, what invalidates it, and what a warm helps.
9496
+
9497
+ \b
9498
+ Answers, per command, the question a warm raises: will this be fast next time?
9499
+ the answer — a warm stores what this command returns
9500
+ the shared work — a warm removes the Java parse / the shared IR; the command
9501
+ still computes its own answer
9502
+ nothing — a warm does not touch it
9503
+
9504
+ One rule covers invalidation: every layer keys on the exact tree state, so any
9505
+ change to the analysed files invalidates it, committed or not.
9506
+ """
9507
+ from sourcecode import cache_model as _cmodel
9508
+
9509
+ if json_output:
9510
+ import json as _j
9511
+ typer.echo(_j.dumps(_cmodel.as_dict(), indent=2, ensure_ascii=False))
9512
+ elif markdown:
9513
+ typer.echo(_cmodel.render_markdown())
9514
+ else:
9515
+ typer.echo(_cmodel.render_text())
9516
+
9348
9517
 
9349
9518
  @cache_app.command("freshness")
9350
9519
  def cache_freshness_cmd(
@@ -190,34 +190,14 @@ def _git_head(repo_root: Path) -> str:
190
190
  def _worktree_signature(repo_root: Path) -> str:
191
191
  """Deterministic fingerprint of the *exact* tree state.
192
192
 
193
- committed HEAD, plus — only when the tree is dirty — a hash of the porcelain
194
- status and the diff against HEAD. Two identical tree states yield the same
195
- signature (correct reuse); any tracked edit changes it (correct invalidation).
196
- Returns ``""`` when the path is not a git repo (caller disables caching).
193
+ Thin alias of the one authority, :func:`sourcecode.cache.worktree_signature`.
194
+ It used to be a second implementation living here, which is how the two caches
195
+ ended up invalidating on different facts: this one on the tree, the snapshot
196
+ cache on the committed HEAD alone.
197
197
  """
198
- head = _git_head(repo_root)
199
- if not head:
200
- return ""
201
- try:
202
- status = subprocess.run(
203
- ["git", "-C", str(repo_root), "status", "--porcelain"],
204
- capture_output=True, text=True, timeout=5,
205
- )
206
- porcelain = status.stdout if status.returncode == 0 else ""
207
- except Exception:
208
- porcelain = ""
209
- if not porcelain.strip():
210
- return head # clean tree — HEAD fully describes it
211
- try:
212
- diff = subprocess.run(
213
- ["git", "-C", str(repo_root), "diff", "HEAD"],
214
- capture_output=True, text=True, timeout=10,
215
- )
216
- diff_txt = diff.stdout if diff.returncode == 0 else ""
217
- except Exception:
218
- diff_txt = ""
219
- dirty = hashlib.sha256((porcelain + "\x00" + diff_txt).encode("utf-8", "replace")).hexdigest()[:16]
220
- return f"{head}+dirty:{dirty}"
198
+ from sourcecode.cache import worktree_signature
199
+
200
+ return worktree_signature(repo_root)
221
201
 
222
202
 
223
203
  def _options_fingerprint(options: Optional[dict[str, Any]]) -> str:
sourcecode/license.py CHANGED
@@ -376,16 +376,78 @@ def _maybe_revalidate() -> None:
376
376
  pass
377
377
 
378
378
 
379
- def _init() -> None:
380
- global _license_data, is_pro
381
- _license_data = _load_license_file()
382
- is_pro = (
379
+ def entitlement() -> dict:
380
+ """The one answer to "what runs here today, and why" (P-2).
381
+
382
+ `auth status` used to publish four fields about this — `status`, `pro`,
383
+ `pro_gating`, `pro_effective`, plus a `pro_reason` sentence — and they read as
384
+ a contradiction: `{"status": "unauthenticated", "pro": true}`. Each field was
385
+ true on its own axis; together they told a buyer nothing about what they have,
386
+ why they have it, or what changes when it ends. That is the ADR-0008 pattern
387
+ exactly, on the commercial surface instead of an analysis one.
388
+
389
+ Every published entitlement field is derived from this dict and nowhere else.
390
+
391
+ Keys:
392
+ entitlement "pro" | "free" — what actually runs right now
393
+ source why: "license_key" | "early_adoption_unlock" | "free_tier"
394
+ authenticated whether a credential is on this machine (an independent
395
+ fact: today one can be entitled to Pro without one)
396
+ paywall_active whether the paywall is enforcing anything at all
397
+ when_it_changes what a user loses when `source` stops applying, in the
398
+ terms the gate actually uses — never a price
399
+ free_repo_java_file_limit
400
+ the number the size gate compares against
401
+ """
402
+ authenticated = _license_data is not None
403
+ licensed = bool(
383
404
  _license_data is not None
384
405
  and _license_data.get("plan") == "pro"
385
406
  and _license_data.get("status", "active") != "inactive"
386
407
  )
387
- if _PRO_UNLOCK_ALL:
388
- is_pro = True # TEMPORARY: early-adoption Pro unlock (see _PRO_UNLOCK_ALL)
408
+ if licensed:
409
+ source = "license_key"
410
+ elif _PRO_UNLOCK_ALL:
411
+ source = "early_adoption_unlock"
412
+ else:
413
+ source = "free_tier"
414
+
415
+ if source == "license_key":
416
+ when = "Your licence entitles you to Pro; nothing changes while it stays active."
417
+ elif source == "early_adoption_unlock":
418
+ when = (
419
+ "Pro runs without a licence during early adoption. When that ends, every command "
420
+ f"still runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} Java "
421
+ "source files; above that, the heavy-analysis commands ask for Pro. Capability is "
422
+ "never what is gated — size, automation and team are."
423
+ )
424
+ else:
425
+ when = (
426
+ f"Every command runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} "
427
+ "Java source files. Above that, the heavy-analysis commands require Pro."
428
+ )
429
+
430
+ return {
431
+ "entitlement": "pro" if (licensed or _PRO_UNLOCK_ALL) else "free",
432
+ "source": source,
433
+ "authenticated": authenticated,
434
+ "paywall_active": not _PRO_UNLOCK_ALL,
435
+ "when_it_changes": when,
436
+ "free_repo_java_file_limit": _FREE_REPO_JAVA_FILE_LIMIT,
437
+ }
438
+
439
+
440
+ def _init() -> None:
441
+ """Load the credential and derive the entitlement from the one authority.
442
+
443
+ `is_pro` is what every gate reads (`can_use`, `require_feature`), and it is
444
+ computed here from `entitlement()` rather than beside it — a second copy of
445
+ this rule is how `auth status` came to publish a state the commands
446
+ contradicted (P-2).
447
+ """
448
+ global _license_data, is_pro
449
+ _license_data = _load_license_file()
450
+ is_pro = entitlement()["entitlement"] == "pro"
389
451
 
390
452
 
391
453
  _init()
@@ -396,7 +458,7 @@ _init()
396
458
  # ---------------------------------------------------------------------------
397
459
 
398
460
  def _emit_telemetry(event: str, **kw: object) -> None:
399
- """Best-effort telemetry emit. Respects the user's opt-out; never raises or blocks."""
461
+ """Best-effort telemetry emit. Sends nothing unless the user opted in; never raises or blocks."""
400
462
  try:
401
463
  from sourcecode import telemetry as _tel
402
464
  _tel.record(event, **kw) # type: ignore[arg-type]
sourcecode/mcp/server.py CHANGED
@@ -33,8 +33,8 @@ def _record_tool_invocation(name: Any, success: bool, started: float) -> None:
33
33
 
34
34
  Aggregate only: which of our own tools ran, whether it succeeded, and a
35
35
  duration bucket. Never the arguments — those carry repository paths — and
36
- never any result content. Honours the same opt-out as every other event
37
- (`ask telemetry disable`, SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1); when
36
+ never any result content. Honours the same opt-in as every other event
37
+ (off until `ask telemetry enable` or SOURCECODE_TELEMETRY=1); when
38
38
  telemetry is off, `record` returns before building anything.
39
39
  """
40
40
  try:
@@ -1090,20 +1090,20 @@ class TaskContextBuilder:
1090
1090
  def _try_ris_fast_path(self, task_name: str, spec: "TaskSpec") -> Optional[TaskOutput]:
1091
1091
  """Return TaskOutput from a warm RIS without running the full scan.
1092
1092
 
1093
- Only activated for onboard/explain when git HEAD matches stored RIS.
1093
+ Only activated for onboard/explain when the stored RIS describes the tree
1094
+ on disk — the same freshness authority every cache layer keys on, not the
1095
+ committed HEAD (which served this fast path through uncommitted edits).
1094
1096
  Falls through (returns None) on any error or cache miss.
1095
1097
  """
1096
1098
  try:
1097
- import subprocess as _sp
1098
1099
  from sourcecode.ris import load_ris as _lris
1100
+ from sourcecode.ris import _tree_signature as _ris_sig
1099
1101
  _ris = _lris(self.root)
1100
1102
  if _ris is None or not _ris.compact_summary:
1101
1103
  return None
1102
- _r = _sp.run(
1103
- ["git", "-C", str(self.root), "rev-parse", "--short", "HEAD"],
1104
- capture_output=True, text=True, timeout=2,
1105
- )
1106
- if _r.returncode != 0 or _r.stdout.strip() != _ris.git_head:
1104
+ _stored_sig = (_ris.metadata or {}).get("tree_signature") or ""
1105
+ _current_sig = _ris_sig(self.root)
1106
+ if not _stored_sig or not _current_sig or _stored_sig != _current_sig:
1107
1107
  return None
1108
1108
 
1109
1109
  compact = _ris.compact_summary
sourcecode/ris.py CHANGED
@@ -258,6 +258,7 @@ def _build_from_core(
258
258
  1.0 if agent_data else (0.5 if compact_data else 0.0)
259
259
  ),
260
260
  "partial": not bool(agent_data),
261
+ "tree_signature": _tree_signature(repo_root),
261
262
  },
262
263
  )
263
264
 
@@ -295,6 +296,7 @@ def maybe_update_ris(repo_root: Path, core_dict: dict, git_head: str) -> None:
295
296
  "snapshot_source": "existing_snapshot_system",
296
297
  "confidence": float(1.0 if agent_data else (0.5 if compact_data else 0.0)),
297
298
  "partial": not bool(agent_data),
299
+ "tree_signature": _tree_signature(repo_root),
298
300
  },
299
301
  )
300
302
  save_ris(repo_root, updated)
@@ -433,6 +435,21 @@ def _current_git_head(repo_root: Path) -> str:
433
435
  return ""
434
436
 
435
437
 
438
+ def _tree_signature(repo_root: Path) -> str:
439
+ """Tree state this snapshot describes — the one freshness authority.
440
+
441
+ Stored in ``metadata["tree_signature"]`` at write time so staleness compares
442
+ the tree the RIS was built from against the tree on disk now. HEAD alone
443
+ called a snapshot fresh while uncommitted edits made it describe nothing.
444
+ """
445
+ try:
446
+ from sourcecode.cache import worktree_signature
447
+
448
+ return worktree_signature(repo_root)
449
+ except Exception:
450
+ return ""
451
+
452
+
436
453
  def _has_uncommitted_changes(repo_root: Path) -> bool:
437
454
  """Return True if working tree has staged or unstaged changes to tracked files.
438
455
 
@@ -465,8 +482,17 @@ def get_cold_start_context(repo_root: Path) -> dict:
465
482
  return {"status": "no_ris"}
466
483
 
467
484
  current_head = _current_git_head(repo_root)
468
- stale = bool(current_head and ris.git_head and current_head != ris.git_head)
469
485
  uncommitted = _has_uncommitted_changes(repo_root)
486
+ # Staleness is about the tree the snapshot describes, not the commit it was
487
+ # taken at: with HEAD alone a snapshot stayed `stale: false` through every
488
+ # uncommitted edit. Snapshots written before the signature existed fall back
489
+ # to the pair of facts that were available then.
490
+ _stored_sig = (ris.metadata or {}).get("tree_signature") or ""
491
+ _current_sig = _tree_signature(repo_root)
492
+ if _stored_sig and _current_sig:
493
+ stale = _stored_sig != _current_sig
494
+ else:
495
+ stale = bool(current_head and ris.git_head and current_head != ris.git_head) or uncommitted
470
496
 
471
497
  endpoints = ris.api_surface.get("endpoints", [])
472
498
  _is_java = (