code-context-control 2.45.0__py3-none-any.whl → 2.46.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.
- cli/c3.py +9 -2
- cli/commands/common.py +1 -1
- cli/docs.html +3 -3
- cli/guide/index.html +31 -1
- cli/guide/tools.html +47 -1
- cli/hook_artifact.py +67 -0
- cli/hook_dispatch.py +1 -0
- cli/hub_server.py +94 -0
- cli/hub_ui/components/drill_artifacts.js +218 -0
- cli/hub_ui/components/drill_panel.js +2 -0
- cli/mcp_server.py +33 -2
- cli/server.py +89 -4
- cli/tools/artifacts.py +160 -0
- cli/tools/edit.py +11 -0
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.dist-info}/METADATA +6 -4
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.dist-info}/RECORD +29 -25
- core/config.py +3 -4
- services/agents.py +44 -44
- services/artifact_defs.py +237 -0
- services/artifact_store.py +644 -0
- services/claude_md.py +19 -1
- services/git_context.py +2 -1
- services/retention.py +29 -2
- services/runtime.py +7 -4
- services/session_manager.py +2 -1
- services/version_tracker.py +0 -263
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.45.0.dist-info → code_context_control-2.46.0.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.
|
|
88
|
+
__version__ = "2.46.0"
|
|
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
|
|
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 & 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> ───
|
|
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 (
|
|
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
|
|
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 & 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 & 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
|
|
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
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} />;
|