code-context-control 2.45.0__py3-none-any.whl → 2.46.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. cli/c3.py +9 -2
  2. cli/commands/common.py +1 -1
  3. cli/docs.html +3 -3
  4. cli/guide/index.html +31 -1
  5. cli/guide/tools.html +47 -1
  6. cli/hook_artifact.py +67 -0
  7. cli/hook_dispatch.py +1 -0
  8. cli/hub_server.py +94 -0
  9. cli/hub_ui/components/drill_artifacts.js +218 -0
  10. cli/hub_ui/components/drill_panel.js +2 -0
  11. cli/mcp_server.py +33 -2
  12. cli/server.py +89 -4
  13. cli/tools/artifacts.py +160 -0
  14. cli/tools/compress.py +1 -2
  15. cli/tools/edit.py +11 -0
  16. cli/tools/federate.py +0 -1
  17. cli/tools/filter.py +1 -2
  18. cli/tools/read.py +1 -2
  19. cli/tools/search.py +1 -2
  20. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/METADATA +6 -4
  21. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/RECORD +36 -32
  22. core/config.py +3 -4
  23. services/agents.py +44 -44
  24. services/artifact_defs.py +237 -0
  25. services/artifact_store.py +644 -0
  26. services/claude_md.py +19 -1
  27. services/git_context.py +2 -1
  28. services/retention.py +29 -2
  29. services/runtime.py +7 -4
  30. services/session_manager.py +2 -1
  31. services/subprojects.py +7 -4
  32. services/task_store.py +1 -1
  33. services/version_tracker.py +0 -263
  34. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/WHEEL +0 -0
  35. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/entry_points.txt +0 -0
  36. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/licenses/LICENSE +0 -0
  37. {code_context_control-2.45.0.dist-info → code_context_control-2.46.1.dist-info}/top_level.txt +0 -0
cli/c3.py CHANGED
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.45.0"
88
+ __version__ = "2.46.1"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -239,7 +239,7 @@ _C3_MCP_ALLOW = [
239
239
  "mcp__c3__c3_memory", "mcp__c3__c3_validate", "mcp__c3__c3_edit",
240
240
  "mcp__c3__c3_agent", "mcp__c3__c3_delegate", "mcp__c3__c3_edits",
241
241
  "mcp__c3__c3_impact", "mcp__c3__c3_shell", "mcp__c3__c3_bitbucket",
242
- "mcp__c3__c3_project", "mcp__c3__c3_task",
242
+ "mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts",
243
243
  ]
244
244
 
245
245
  # Obsolete MCP tool names from earlier C3 versions. `c3 permissions clean`
@@ -4980,6 +4980,11 @@ def cmd_install_mcp(args):
4980
4980
  ) from e
4981
4981
 
4982
4982
  print(f"Wrote {mcp_config_path}")
4983
+ if not profile.config_path_global:
4984
+ # Self-report to the artifact tracker: attribute this write to C3
4985
+ # instead of letting it surface as anonymous out-of-band drift.
4986
+ from services.artifact_defs import note_pending_write
4987
+ note_pending_write(target, profile.config_path, "install_mcp")
4983
4988
  if profile.name in {"codex", "gemini"}:
4984
4989
  _ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe)
4985
4990
  _ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe)
@@ -5181,6 +5186,8 @@ def cmd_install_mcp(args):
5181
5186
  json.dump(settings, f, indent=2)
5182
5187
 
5183
5188
  print(f"Wrote {settings_path}")
5189
+ from services.artifact_defs import note_pending_write
5190
+ note_pending_write(target, profile.settings_path, "install_mcp")
5184
5191
  print(f" Hooks ({hook_event}): dispatcher (1 spawn/event) — filter/ghost/read-guard/ledger/unlock/signal via cli/hook_dispatch.py posttool")
5185
5192
  print(f" Hooks ({pre_event}): dispatcher — {read_matcher}/{grep_matcher}/{glob_matcher}/{edit_matcher}/{write_matcher} (c3 enforcement)")
5186
5193
  print(" Hooks (Stop): dispatcher — session_stats + auto_snapshot + terse_advisor")
cli/commands/common.py CHANGED
@@ -202,7 +202,7 @@ def cmd_claudemd(args, deps: CommandDeps):
202
202
  output_path = Path(project_path) / instructions_file
203
203
  # Wrap in the C3 managed block; preserve user content outside it.
204
204
  from services.claude_md import write_c3_instruction_doc
205
- write_c3_instruction_doc(output_path, content)
205
+ write_c3_instruction_doc(output_path, content, project_path=project_path)
206
206
  print(f"{instructions_file} saved to {output_path} ({tokens} tokens)")
207
207
 
208
208
  elif args.claudemd_cmd == "check":
cli/docs.html CHANGED
@@ -1148,7 +1148,7 @@ python cli/c3.py install-mcp . gemini</code></pre>
1148
1148
 
1149
1149
  <!-- ─── MCP Tools ───────────────────── -->
1150
1150
  <h2 id="mcp-tools">MCP Tools Reference</h2>
1151
- <p>C3 exposes 16 MCP tools. All core tools work without Ollama; delegate requires it. The Bitbucket integration is optional and activated via <code>c3 bitbucket login</code>.</p>
1151
+ <p>C3 exposes 18 MCP tools. All core tools work without Ollama; delegate requires it. The Bitbucket integration is optional and activated via <code>c3 bitbucket login</code>.</p>
1152
1152
 
1153
1153
  <h3>Discovery &amp; Compression</h3>
1154
1154
  <table>
@@ -2672,7 +2672,7 @@ c3 session load | claude --resume</code></pre>
2672
2672
  <strong>MCP Proxy</strong> ──── Dynamic tool filtering + context injection (optional, opt-in via MCP config)
2673
2673
  | Subprocess stdio (NDJSON)
2674
2674
  v
2675
- <strong>C3 MCP Server</strong> ─── 26 tools: search, compress, session, memory, CLAUDE.md, context, hybrid, notifications...
2675
+ <strong>C3 MCP Server</strong> ─── 18 tools: search, compress, session, memory, edits, tasks, artifacts, delegate...
2676
2676
  |
2677
2677
  +── <em>Compression</em> <em>Smart Index</em>
2678
2678
  | AST Summary TF-IDF + Code Structure
@@ -2861,7 +2861,7 @@ c3 session load | claude --resume</code></pre>
2861
2861
  <pre><code>claude-companion/
2862
2862
  cli/
2863
2863
  c3.py # CLI entry point (all commands)
2864
- mcp_server.py # MCP server (30 tools via FastMCP)
2864
+ mcp_server.py # MCP server (18 tools via FastMCP)
2865
2865
  mcp_proxy.py # Optional advanced MCP proxy (tool filtering)
2866
2866
  server.py # Flask web server + REST API
2867
2867
  ui.html # Single-page React dashboard
cli/guide/index.html CHANGED
@@ -309,7 +309,7 @@
309
309
  <a href="tools.html" class="nav-card">
310
310
  <span class="nav-card-icon">🔧</span>
311
311
  <div class="nav-card-title">Tools Reference</div>
312
- <div class="nav-card-desc">Full reference for all 16 MCP tools — params, examples, notes</div>
312
+ <div class="nav-card-desc">Full reference for all 18 MCP tools — params, examples, notes</div>
313
313
  </a>
314
314
  <a href="bitbucket.html" class="nav-card">
315
315
  <span class="nav-card-icon">🪣</span>
@@ -509,6 +509,36 @@
509
509
  <td><span class="badge badge-yellow">Ledger</span></td>
510
510
  <td>Edit ledger: history, versions, audit trail</td>
511
511
  </tr>
512
+ <tr>
513
+ <td><code>c3_impact</code></td>
514
+ <td><span class="badge badge-purple">Analysis</span></td>
515
+ <td>Blast-radius check before editing shared symbols</td>
516
+ </tr>
517
+ <tr>
518
+ <td><code>c3_shell</code></td>
519
+ <td><span class="badge badge-green">Execute</span></td>
520
+ <td>Structured shell exec — tests, git, build (auto-filtered)</td>
521
+ </tr>
522
+ <tr>
523
+ <td><code>c3_task</code></td>
524
+ <td><span class="badge badge-yellow">PM</span></td>
525
+ <td>Durable tasks, milestones, and decision notes per project</td>
526
+ </tr>
527
+ <tr>
528
+ <td><code>c3_artifacts</code></td>
529
+ <td><span class="badge badge-yellow">Config</span></td>
530
+ <td>Agent-config tracking: history, diff &amp; restore for CLAUDE.md, settings, MCP configs, skills</td>
531
+ </tr>
532
+ <tr>
533
+ <td><code>c3_bitbucket</code></td>
534
+ <td><span class="badge badge-orange">SCM</span></td>
535
+ <td>Bitbucket Data Center: PRs, branches, builds, repo admin</td>
536
+ </tr>
537
+ <tr>
538
+ <td><code>c3_project</code></td>
539
+ <td><span class="badge badge-cyan">Multi-project</span></td>
540
+ <td>Discover &amp; operate on other c3-installed projects</td>
541
+ </tr>
512
542
  </tbody>
513
543
  </table>
514
544
 
cli/guide/tools.html CHANGED
@@ -118,6 +118,7 @@
118
118
  <div class="sidebar-section">
119
119
  <div class="sidebar-label">Ledger</div>
120
120
  <a href="#c3_edits" class="sidebar-link"><span class="icon">📝</span> c3_edits</a>
121
+ <a href="#c3_artifacts" class="sidebar-link"><span class="icon">🛡️</span> c3_artifacts</a>
121
122
  </div>
122
123
  <div class="sidebar-section">
123
124
  <div class="sidebar-label">SCM</div>
@@ -135,7 +136,7 @@
135
136
 
136
137
  <div class="page-hero">
137
138
  <h1>Tools Reference</h1>
138
- <p>Complete reference for all 16 C3 MCP tools — parameters, actions, examples, and usage notes.</p>
139
+ <p>Complete reference for all 18 C3 MCP tools — parameters, actions, examples, and usage notes.</p>
139
140
  </div>
140
141
 
141
142
  <!-- TOC -->
@@ -157,6 +158,7 @@
157
158
  <a href="#c3_agent" class="toc-item">c3_agent <span class="cat">AI</span></a>
158
159
  <a href="#c3_edits" class="toc-item">c3_edits <span class="cat">Ledger</span></a>
159
160
  <a href="#c3_task" class="toc-item">c3_task <span class="cat">PM</span></a>
161
+ <a href="#c3_artifacts" class="toc-item">c3_artifacts <span class="cat">Config</span></a>
160
162
  <a href="#c3_bitbucket" class="toc-item">c3_bitbucket <span class="cat">SCM</span></a>
161
163
  <a href="#c3_project" class="toc-item">c3_project <span class="cat">Multi-project</span></a>
162
164
  </div>
@@ -1138,6 +1140,50 @@ c3_task(action=<span class="str">'note_add'</span>, note=<span class="str">'Kanb
1138
1140
  </div>
1139
1141
  </div>
1140
1142
 
1143
+ <!-- c3_artifacts -->
1144
+ <div class="tool-card" id="c3_artifacts">
1145
+ <div class="tool-card-header">
1146
+ <span class="tool-name">c3_artifacts</span>
1147
+ <div class="tag-row">
1148
+ <span class="badge badge-purple">agent config</span>
1149
+ <span class="badge">v2.46.0</span>
1150
+ </div>
1151
+ <span class="tool-tagline">Version history, diff, and restore for the files that shape the agent itself</span>
1152
+ </div>
1153
+ <div class="tool-card-body">
1154
+ <p class="tool-desc">C3 tracks every <em>agent-affecting artifact</em> across all IDEs it knows — instruction docs (<code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>GEMINI.md</code>, <code>.cursorrules</code>, <code>.github/copilot-instructions.md</code>), settings/hooks (<code>.claude/settings*.json</code>), MCP configs (<code>.mcp.json</code>, <code>.codex/config.toml</code>, <code>.gemini/settings.json</code>, …), and Claude Code extensions (<code>.claude/</code> skills, agents, commands, plugins). Content-addressed snapshots land in <code>.c3/agent_artifacts/</code>; every change is a history event with attribution: <code>c3_edit</code> (this session), <code>hook</code> (native Edit/Write), <code>scan</code> (out-of-band — you edited it in an editor), <code>install_mcp</code> (C3 regenerating its own files), or <code>restore</code>. A background agent scans every ~2 min and warns only when <em>settings or MCP configs</em> change outside C3. Artifact refs accept an id (<code>skill:browcontrol</code>), unique prefix, or plain path (<code>CLAUDE.md</code>). Everything except <code>restore</code> is plan-mode-safe.</p>
1155
+
1156
+ <h4>Actions</h4>
1157
+ <table class="params-table">
1158
+ <thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
1159
+ <tbody>
1160
+ <tr><td class="param-name">scan</td><td>Read</td><td class="param-desc">Refresh the inventory; captures out-of-band changes immediately (idempotent — unchanged files emit nothing)</td></tr>
1161
+ <tr><td class="param-name">list</td><td>Read</td><td class="param-desc">Inventory with versions; filters: <code>cls</code> (instructions | settings | mcp | skill | agent | command | plugin), <code>provider</code></td></tr>
1162
+ <tr><td class="param-name">history</td><td>Read</td><td class="param-desc">Change events newest-first, with source attribution; <code>artifact</code> optional (all events when omitted)</td></tr>
1163
+ <tr><td class="param-name">show</td><td>Read</td><td class="param-desc">Content at a version (<code>version=0</code> → live file)</td></tr>
1164
+ <tr><td class="param-name">diff</td><td>Read</td><td class="param-desc">Unified diff <code>version</code> → <code>against</code> (omit <code>against</code> to diff vs live)</td></tr>
1165
+ <tr><td class="param-name">restore</td><td>Write</td><td class="param-desc">Write a prior version's exact bytes back. Forward-only (new version + history event, never rewrites), cross-logged to the edit ledger, warns on settings/managed-block files. Resurrects deleted artifacts.</td></tr>
1166
+ <tr><td class="param-name">status</td><td>Read</td><td class="param-desc">Tracked counts by class, out-of-band changes, last scan, pending signals</td></tr>
1167
+ </tbody>
1168
+ </table>
1169
+
1170
+ <h4>Examples</h4>
1171
+ <pre><code><span class="com"># What changed behind my back?</span>
1172
+ c3_artifacts(action=<span class="str">'status'</span>)
1173
+ c3_artifacts(action=<span class="str">'history'</span>, limit=<span class="num">10</span>)
1174
+
1175
+ <span class="com"># Someone (or something) touched the hooks — inspect and roll back</span>
1176
+ c3_artifacts(action=<span class="str">'diff'</span>, artifact=<span class="str">'.claude/settings.local.json'</span>, version=<span class="num">3</span>)
1177
+ c3_artifacts(action=<span class="str">'restore'</span>, artifact=<span class="str">'settings:.claude/settings.local.json'</span>, version=<span class="num">3</span>)
1178
+
1179
+ <span class="com"># Track a skill's evolution</span>
1180
+ c3_artifacts(action=<span class="str">'history'</span>, artifact=<span class="str">'skill:browcontrol'</span>)
1181
+ c3_artifacts(action=<span class="str">'show'</span>, artifact=<span class="str">'skill:browcontrol'</span>, version=<span class="num">2</span>)</code></pre>
1182
+
1183
+ <p class="tool-desc"><strong>Surfaces:</strong> the Hub drill-in panel gets an Artifacts tab (class-grouped inventory, per-version timeline, diff viewer, two-step restore); REST at <code>/api/artifacts*</code> per project and <code>/api/projects/artifacts*</code> on the hub. Changes made through <code>c3_edit</code> and <code>install-mcp</code> self-attribute — only genuinely foreign edits show as <code>scan</code>. Disable with <code>hybrid.agent_artifacts.enabled=false</code>.</p>
1184
+ </div>
1185
+ </div>
1186
+
1141
1187
  <!-- c3_bitbucket -->
1142
1188
  <div class="tool-card" id="c3_bitbucket">
1143
1189
  <div class="tool-card-header">
cli/hook_artifact.py ADDED
@@ -0,0 +1,67 @@
1
+ """PostToolUse hook: signal agent-artifact writes for attributed capture.
2
+
3
+ Fires alongside the edit ledger on Edit/Write/NotebookEdit. When the touched
4
+ file classifies as an agent-affecting artifact (instruction docs, settings/
5
+ hooks, MCP configs, .claude skills/agents/commands — see
6
+ services/artifact_defs), appends a one-line pending signal to
7
+ .c3/agent_artifacts/pending.jsonl.
8
+
9
+ Performance: no hashing, no manifest lock — the ArtifactScanAgent consumes
10
+ signals asynchronously and attributes the resulting history events to
11
+ source='hook'. Silent hook: never emits user-visible output.
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Add project root to path for imports
19
+ _project_root = str(Path(__file__).resolve().parent.parent)
20
+ if _project_root not in sys.path:
21
+ sys.path.insert(0, _project_root)
22
+
23
+ from cli._hook_utils import get_tool_input_path, log_hook_error, normalize_tool_name # noqa: E402
24
+ from services.artifact_defs import classify_path, note_pending_write # noqa: E402
25
+
26
+
27
+ def run(payload: dict, project_path: Path | None = None) -> None:
28
+ """Core logic — importable by the dispatcher and tests. Always None."""
29
+ tool_name = normalize_tool_name(payload.get("tool_name", ""))
30
+ if tool_name not in ("Edit", "Write", "NotebookEdit"):
31
+ return None
32
+
33
+ file_path = get_tool_input_path(payload)
34
+ if not file_path:
35
+ return None
36
+
37
+ if project_path is None:
38
+ project_path = Path.cwd()
39
+ if not (project_path / ".c3").exists():
40
+ return None # Not a C3 project
41
+
42
+ try:
43
+ rel = str(Path(file_path).resolve().relative_to(project_path.resolve()))
44
+ except (ValueError, OSError):
45
+ return None # outside the project → not a project artifact
46
+
47
+ if classify_path(rel) is None:
48
+ return None
49
+
50
+ note_pending_write(project_path, rel, "hook",
51
+ session_id=payload.get("session_id", "") or "",
52
+ tool=tool_name)
53
+ return None
54
+
55
+
56
+ def main():
57
+ try:
58
+ raw = sys.stdin.read()
59
+ if not raw.strip():
60
+ return
61
+ run(json.loads(raw))
62
+ except Exception as _e:
63
+ log_hook_error("hook_artifact", _e)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
cli/hook_dispatch.py CHANGED
@@ -96,6 +96,7 @@ def _routes(event: str, raw_tool: str, norm_tool: str):
96
96
  yield "hook_c3_signal"
97
97
  if norm_tool in _LEDGER_TOOLS:
98
98
  yield "hook_edit_ledger"
99
+ yield "hook_artifact"
99
100
  if raw_tool in _GHOST_TOOLS:
100
101
  yield "hook_ghost_files"
101
102
  elif event == "stop":
cli/hub_server.py CHANGED
@@ -335,6 +335,7 @@ _HUB_JS_FILES = [
335
335
  "hub_ui/components/drill_views.js",
336
336
  "hub_ui/components/drill_health.js",
337
337
  "hub_ui/components/drill_tasks.js",
338
+ "hub_ui/components/drill_artifacts.js",
338
339
  "hub_ui/components/config_editor.js",
339
340
  "hub_ui/components/mcp_manager.js",
340
341
  "hub_ui/components/global_search.js",
@@ -1892,6 +1893,99 @@ def api_pm_link():
1892
1893
  return jsonify({"task": res})
1893
1894
 
1894
1895
 
1896
+ # ── Agent artifacts: config tracking (v2.46.0) ────────────────────────────
1897
+ # Direct ArtifactStore per request (load-per-op store — no runtime build
1898
+ # needed); every mutation audited to the target project's activity log.
1899
+
1900
+ def _artifact_store_for(path):
1901
+ from services.artifact_store import ArtifactStore
1902
+ return ArtifactStore(str(path))
1903
+
1904
+
1905
+ def _artifact_audit(path, op, ref=""):
1906
+ try:
1907
+ ActivityLog(str(path)).log("artifact_write", {
1908
+ "op": op, "ref": ref, "source": "hub"})
1909
+ except Exception:
1910
+ pass
1911
+
1912
+
1913
+ @app.route("/api/projects/artifacts", methods=["GET"])
1914
+ def api_projects_artifacts():
1915
+ """Artifact inventory + tracker status. Query: path, cls?, provider?"""
1916
+ resolved, err = _pm_resolve((request.args.get("path") or "").strip())
1917
+ if err:
1918
+ return err
1919
+ store = _artifact_store_for(resolved)
1920
+ return jsonify({
1921
+ "path": str(resolved),
1922
+ "artifacts": store.list_artifacts(
1923
+ cls=request.args.get("cls", ""),
1924
+ provider=request.args.get("provider", "")),
1925
+ "status": store.status(),
1926
+ })
1927
+
1928
+
1929
+ @app.route("/api/projects/artifacts/history", methods=["GET"])
1930
+ def api_projects_artifacts_history():
1931
+ """History events, newest first. Query: path, artifact?, limit?"""
1932
+ resolved, err = _pm_resolve((request.args.get("path") or "").strip())
1933
+ if err:
1934
+ return err
1935
+ try:
1936
+ limit = max(1, min(int(request.args.get("limit") or 50), 500))
1937
+ except (TypeError, ValueError):
1938
+ limit = 50
1939
+ return jsonify({"events": _artifact_store_for(resolved).get_history(
1940
+ artifact=request.args.get("artifact", ""), limit=limit)})
1941
+
1942
+
1943
+ @app.route("/api/projects/artifacts/scan", methods=["POST"])
1944
+ def api_projects_artifacts_scan():
1945
+ data = request.get_json(force=True) or {}
1946
+ resolved, err = _pm_resolve((data.get("path") or "").strip())
1947
+ if err:
1948
+ return err
1949
+ store = _artifact_store_for(resolved)
1950
+ store.consume_pending()
1951
+ res = store.scan()
1952
+ res.pop("events", None) # event dicts live on the history endpoint
1953
+ _artifact_audit(resolved, "scan")
1954
+ return jsonify(res)
1955
+
1956
+
1957
+ @app.route("/api/projects/artifacts/diff", methods=["POST"])
1958
+ def api_projects_artifacts_diff():
1959
+ data = request.get_json(force=True) or {}
1960
+ resolved, err = _pm_resolve((data.get("path") or "").strip())
1961
+ if err:
1962
+ return err
1963
+ if not data.get("artifact") or not data.get("version"):
1964
+ return jsonify({"error": "artifact and version are required"}), 400
1965
+ res = _artifact_store_for(resolved).diff(
1966
+ data["artifact"], int(data["version"]),
1967
+ int(data["against"]) if data.get("against") else None)
1968
+ if "error" in res:
1969
+ return jsonify(res), 400
1970
+ return jsonify(res)
1971
+
1972
+
1973
+ @app.route("/api/projects/artifacts/restore", methods=["POST"])
1974
+ def api_projects_artifacts_restore():
1975
+ data = request.get_json(force=True) or {}
1976
+ resolved, err = _pm_resolve((data.get("path") or "").strip())
1977
+ if err:
1978
+ return err
1979
+ if not data.get("artifact") or not data.get("version"):
1980
+ return jsonify({"error": "artifact and version are required"}), 400
1981
+ res = _artifact_store_for(resolved).restore(
1982
+ data["artifact"], int(data["version"]), session_id="hub")
1983
+ if "error" in res:
1984
+ return jsonify(res), 400
1985
+ _artifact_audit(resolved, "restore", f"{res['id']}@v{data['version']}")
1986
+ return jsonify(res)
1987
+
1988
+
1895
1989
  @app.route("/api/pm/global", methods=["GET"])
1896
1990
  def api_pm_global():
1897
1991
  """Open tasks across every registered project (raw registry — no port probes)."""
@@ -0,0 +1,218 @@
1
+ // ─── Drill panel: Artifacts tab (agent-config tracking) ────────
2
+ // Inventory of agent-affecting files (instruction docs, settings/hooks,
3
+ // MCP configs, .claude skills/agents/commands) with version history,
4
+ // diff viewer and restore. Reads GET /api/projects/artifacts?path=…;
5
+ // history via /api/projects/artifacts/history; scan/diff/restore POST
6
+ // to the matching hub endpoints. A 409 renders DrillNeedsInit.
7
+
8
+ const ARTIFACT_CLASS_LABELS = {
9
+ instructions: 'Instructions', settings: 'Settings', mcp: 'MCP config',
10
+ skill: 'Skills', agent: 'Agents', command: 'Commands', plugin: 'Plugins',
11
+ };
12
+
13
+ function artifactClassColor(cls) {
14
+ return ({
15
+ instructions: T.blue, settings: T.warn, mcp: T.purple,
16
+ skill: T.accent, agent: T.blue, command: T.warn, plugin: T.purple,
17
+ })[cls] || T.textMuted;
18
+ }
19
+
20
+ function DrillArtifactHistory({ project, art, onChanged }) {
21
+ const [events, setEvents] = useState(null);
22
+ const [diff, setDiff] = useState(null); // {label, text}
23
+ const [confirmV, setConfirmV] = useState(null); // version pending confirm
24
+ const [warnings, setWarnings] = useState([]);
25
+
26
+ const load = async () => {
27
+ try {
28
+ const d = await api.get('/api/projects/artifacts/history?path='
29
+ + encodeURIComponent(project.path)
30
+ + '&artifact=' + encodeURIComponent(art.id) + '&limit=30');
31
+ setEvents(d.events || []);
32
+ } catch (e) { notify('History failed: ' + e.message, 'err'); }
33
+ };
34
+ useEffect(() => { load(); }, [art.id]);
35
+
36
+ const showDiff = async (v, against) => {
37
+ try {
38
+ const body = { path: project.path, artifact: art.id, version: v };
39
+ if (against) body.against = against;
40
+ const d = await api.post('/api/projects/artifacts/diff', body);
41
+ setDiff({ label: `${d.from} → ${d.to} (+${d.plus} −${d.minus})`, text: d.diff });
42
+ } catch (e) { notify('Diff failed: ' + e.message, 'err'); }
43
+ };
44
+
45
+ const restore = async (v) => {
46
+ if (confirmV !== v) { setConfirmV(v); setTimeout(() => setConfirmV(null), 4000); return; }
47
+ setConfirmV(null);
48
+ try {
49
+ const d = await api.post('/api/projects/artifacts/restore',
50
+ { path: project.path, artifact: art.id, version: v });
51
+ notify(`Restored ${d.id} v${v} → live as v${d.new_version}`, 'ok');
52
+ setWarnings(d.warnings || []);
53
+ setDiff(null);
54
+ load();
55
+ if (onChanged) onChanged();
56
+ } catch (e) { notify('Restore failed: ' + e.message, 'err'); }
57
+ };
58
+
59
+ if (events === null) return <DrillMsg text="Loading history…" />;
60
+ if (!events.length) return <DrillMsg text="No recorded events yet." />;
61
+
62
+ return (
63
+ <div style={{ padding: '6px 0 10px 22px' }}>
64
+ {warnings.map((w, i) => (
65
+ <div key={i} style={{
66
+ fontSize: 11, color: T.warn, padding: '3px 0', lineHeight: 1.4,
67
+ }}>⚠ {w}</div>
68
+ ))}
69
+ {events.map(ev => (
70
+ <div key={ev.id} style={{
71
+ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0',
72
+ borderBottom: `1px solid ${T.border}`, minWidth: 0,
73
+ }}>
74
+ <span className="mono" style={{ fontSize: 11, color: T.text, flexShrink: 0 }}>
75
+ v{ev.version}
76
+ </span>
77
+ <Badge color={ev.event === 'deleted' ? T.error
78
+ : ev.event === 'restored' ? T.accent : T.blue}>{ev.event}</Badge>
79
+ <Badge color={ev.source === 'scan' ? T.warn : T.textMuted}>{ev.source}</Badge>
80
+ <span title={ev.summary || ''} style={{
81
+ fontSize: 11, color: T.textMuted, flex: 1, minWidth: 0,
82
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
83
+ }}>{(ev.ts || '').replace('T', ' ')}{ev.summary ? ' — ' + ev.summary : ''}</span>
84
+ {ev.event !== 'deleted' && (
85
+ <React.Fragment>
86
+ <button onClick={() => showDiff(ev.version)} title="Diff this version vs live"
87
+ style={{ background: 'none', border: 'none', cursor: 'pointer',
88
+ color: T.blue, fontSize: 11, padding: '2px 4px', flexShrink: 0 }}>
89
+ diff
90
+ </button>
91
+ <button onClick={() => restore(ev.version)} title="Restore this version"
92
+ style={{ background: 'none', border: 'none', cursor: 'pointer',
93
+ color: confirmV === ev.version ? T.error : T.warn,
94
+ fontSize: 11, fontWeight: confirmV === ev.version ? 700 : 400,
95
+ padding: '2px 4px', flexShrink: 0 }}>
96
+ {confirmV === ev.version ? 'confirm?' : 'restore'}
97
+ </button>
98
+ </React.Fragment>
99
+ )}
100
+ </div>
101
+ ))}
102
+ {diff && (
103
+ <div style={{ marginTop: 10 }}>
104
+ <div style={{ fontSize: 11, color: T.textMuted, marginBottom: 4 }}>
105
+ {diff.label}
106
+ <button onClick={() => setDiff(null)} style={{
107
+ background: 'none', border: 'none', cursor: 'pointer',
108
+ color: T.textDim, fontSize: 11, marginLeft: 8 }}>× close</button>
109
+ </div>
110
+ <pre className="mono" style={{
111
+ background: T.surfaceAlt, border: `1px solid ${T.border}`, borderRadius: 6,
112
+ padding: 10, fontSize: 11, lineHeight: 1.5, overflow: 'auto',
113
+ maxHeight: 320, whiteSpace: 'pre-wrap', color: T.text, margin: 0,
114
+ }}>{diff.text}</pre>
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function DrillArtifacts({ project, onChanged }) {
122
+ const [data, setData] = useState(null);
123
+ const [err, setErr] = useState(null);
124
+ const [needsInit, setNeedsInit] = useState(false);
125
+ const [openId, setOpenId] = useState(null);
126
+ const [scanning, setScanning] = useState(false);
127
+
128
+ const load = async () => {
129
+ setErr(null);
130
+ try {
131
+ const d = await api.get('/api/projects/artifacts?path='
132
+ + encodeURIComponent(project.path));
133
+ setData(d);
134
+ setNeedsInit(false);
135
+ } catch (e) {
136
+ if (e.status === 409) setNeedsInit(true);
137
+ else setErr(e.message);
138
+ }
139
+ };
140
+ useEffect(() => {
141
+ setData(null); setNeedsInit(false); setOpenId(null);
142
+ load();
143
+ }, [project.path]);
144
+
145
+ const scan = async () => {
146
+ if (scanning) return;
147
+ setScanning(true);
148
+ try {
149
+ const d = await api.post('/api/projects/artifacts/scan', { path: project.path });
150
+ notify(`Scan: ${d.added.length} added, ${d.modified.length} modified, `
151
+ + `${d.deleted.length} deleted`, 'ok');
152
+ load();
153
+ if (onChanged) onChanged();
154
+ } catch (e) { notify('Scan failed: ' + e.message, 'err'); }
155
+ setScanning(false);
156
+ };
157
+
158
+ if (needsInit) return <DrillNeedsInit project={project} onReady={load} />;
159
+ if (err) return <DrillMsg text={err} color={T.error} />;
160
+ if (!data) return <DrillMsg text="Loading artifacts…" />;
161
+
162
+ const arts = data.artifacts || [];
163
+ const st = data.status || {};
164
+ const byClass = {};
165
+ arts.forEach(a => { (byClass[a.class] = byClass[a.class] || []).push(a); });
166
+
167
+ return (
168
+ <div>
169
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
170
+ <span style={{ fontSize: 12, color: T.textMuted, flex: 1 }}>
171
+ {st.tracked || 0} tracked · {st.out_of_band_recent || 0} recent out-of-band
172
+ {st.last_scan ? ` · last scan ${String(st.last_scan).replace('T', ' ')}` : ''}
173
+ </span>
174
+ <Btn onClick={scan} disabled={scanning}>{scanning ? 'Scanning…' : 'Scan now'}</Btn>
175
+ </div>
176
+ {!arts.length && (
177
+ <DrillMsg text="Nothing tracked yet — run a scan to build the inventory." />
178
+ )}
179
+ {Object.keys(ARTIFACT_CLASS_LABELS).filter(c => byClass[c]).map(cls => (
180
+ <DrillSection key={cls} label={ARTIFACT_CLASS_LABELS[cls]} style={{ marginTop: 18 }}>
181
+ {byClass[cls].map(a => (
182
+ <div key={a.id}>
183
+ <div onClick={() => setOpenId(openId === a.id ? null : a.id)} style={{
184
+ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 0',
185
+ borderBottom: `1px solid ${T.border}`, cursor: 'pointer', minWidth: 0,
186
+ }}>
187
+ <span style={{
188
+ width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
189
+ background: artifactClassColor(a.class),
190
+ opacity: a.exists ? 1 : 0.35,
191
+ }} />
192
+ <span className="mono" title={a.root} style={{
193
+ fontSize: 12, color: a.exists ? T.text : T.textDim, flex: 1, minWidth: 0,
194
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
195
+ textDecoration: a.exists ? 'none' : 'line-through',
196
+ }}>{a.id}</span>
197
+ {!a.exists && <Badge color={T.error}>deleted</Badge>}
198
+ {(a.roles || []).map(r => <Badge key={r} color={T.textMuted}>{r}</Badge>)}
199
+ <span className="mono" style={{ fontSize: 11, color: T.textMuted, flexShrink: 0 }}>
200
+ v{a.version}
201
+ </span>
202
+ <span style={{ fontSize: 11, color: T.textDim, flexShrink: 0 }}>
203
+ {a.files} file{a.files === 1 ? '' : 's'}
204
+ </span>
205
+ <span className="mono" style={{ fontSize: 10, color: T.textDim, flexShrink: 0 }}>
206
+ {(a.last_changed || '').slice(0, 10)}
207
+ </span>
208
+ </div>
209
+ {openId === a.id && (
210
+ <DrillArtifactHistory project={project} art={a} onChanged={onChanged} />
211
+ )}
212
+ </div>
213
+ ))}
214
+ </DrillSection>
215
+ ))}
216
+ </div>
217
+ );
218
+ }
@@ -6,6 +6,7 @@
6
6
  const DRILL_PANEL_TABS = [
7
7
  ['overview', 'Overview'],
8
8
  ['tasks', 'Tasks'],
9
+ ['artifacts', 'Artifacts'],
9
10
  ['memory', 'Memory'],
10
11
  ['ledger', 'Ledger'],
11
12
  ['sessions', 'Sessions'],
@@ -109,6 +110,7 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal }) {
109
110
  const renderTab = () => {
110
111
  switch (tab) {
111
112
  case 'tasks': return <DrillTasks project={project} onChanged={onChanged} />;
113
+ case 'artifacts': return <DrillArtifacts project={project} onChanged={onChanged} />;
112
114
  case 'memory': return <DrillMemory project={project} />;
113
115
  case 'ledger': return <DrillLedger project={project} />;
114
116
  case 'sessions': return <DrillSessions project={project} />;