feat(onboarding): redesign repo integration journey

custodian_cli.py:
- register-project now writes CLAUDE.custodian.md (suggestion) instead
  of overwriting CLAUDE.md; includes preamble with integration instructions
- registers repo via POST /repos/
- creates a "Repo Integration: {slug}" workstream in the domain's topic
  with 4 onboarding tasks (integrate CLAUDE.md, first workplan, SBOM,
  EPs/TDs); checks for existing workstream to be idempotent
- fixes {REPO_SLUG} template substitution (previously missing)

dashboard:
- repos.md: fetches workstreams; detects active repo-integration-* slugs;
  adds "Integrating" KPI card; shows ⚙ integrating badge per repo in
  coverage map and table; replaces "How to Ingest a Repo" with
  "Onboard a New Repo" 4-step panel with doc help button
- docs/repo-integration.md (new): full collaboration model doc — custodian
  as coach, repo agent as executor; journey, generated tasks, first session
  protocol, ongoing relationship
- docs/repos.md: links to new repo-integration doc; updates "What is a
  managed repo?" section; adds onboarding quick reference
- docs/reference.md: fix latent build error — code examples were in ```js
  fences (executed by OF); changed to ```javascript (display only)
- observablehq.config.js: adds "Repo Integration" to Reference nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 08:42:30 +01:00
parent 8a9314ded6
commit fe6704b9d0
6 changed files with 423 additions and 75 deletions

View File

@@ -7,31 +7,38 @@ const API = "http://127.0.0.1:8000";
```
```js
let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _contribs = [];
let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _workstreams = [];
try {
[_repos, _domains, _sbom, _eps, _tds, _contribs] = await Promise.all([
[_repos, _domains, _sbom, _eps, _tds, _workstreams] = await Promise.all([
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/contributions/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/workstreams/`).then(r => r.ok ? r.json() : []),
]);
} catch {}
```
```js
const repos = _repos ?? [];
const domains = _domains ?? [];
const sbom = _sbom ?? [];
const eps = _eps ?? [];
const tds = _tds ?? [];
const contribs = _contribs ?? [];
const repos = _repos ?? [];
const domains = _domains ?? [];
const sbom = _sbom ?? [];
const eps = _eps ?? [];
const tds = _tds ?? [];
const workstreams = _workstreams ?? [];
// Lookups
const domainById = Object.fromEntries(domains.map(d => [d.id, d]));
const domainBySlug = Object.fromEntries(domains.map(d => [d.slug, d]));
// Active "repo-integration-{slug}" workstreams — signals onboarding in progress
const integratingBySlug = Object.fromEntries(
workstreams
.filter(w => w.status === "active" && w.slug?.startsWith("repo-integration-"))
.map(w => [w.slug.replace("repo-integration-", ""), w])
);
// Per-repo SBOM stats (from sbom entries)
const sbomByRepo = {};
for (const e of sbom) {
@@ -71,23 +78,27 @@ const repoRows = repos
const lastScan = r.last_sbom_at
? new Date(r.last_sbom_at).toLocaleDateString()
: (sbomData?.snapshot_at ? new Date(sbomData.snapshot_at).toLocaleDateString() : null);
const integrating = !!integratingBySlug[r.slug];
return {
_id: r.id,
_domSlug: domSlug,
_hasSbom: hasSbom,
repo: r.slug,
domain: domName,
path: r.local_path ?? "—",
sbom: hasSbom ? `${lastScan}` : "⚠ not ingested",
pkgs: pkgCount || (hasSbom ? "—" : 0),
eps: epByDomain[domSlug] ?? 0,
tds: tdByDomain[domSlug] ?? 0,
_id: r.id,
_domSlug: domSlug,
_hasSbom: hasSbom,
_integrating: integrating,
repo: r.slug,
domain: domName,
status: integrating ? "⚙ integrating" : "ready",
path: r.local_path ?? "—",
sbom: hasSbom ? `${lastScan}` : "⚠ not ingested",
pkgs: pkgCount || (hasSbom ? "—" : 0),
eps: epByDomain[domSlug] ?? 0,
tds: tdByDomain[domSlug] ?? 0,
};
})
.sort((a, b) => a._domSlug.localeCompare(b._domSlug) || a.repo.localeCompare(b.repo));
const gapCount = repoRows.filter(r => !r._hasSbom).length;
const coveredCount = repoRows.filter(r => r._hasSbom).length;
const gapCount = repoRows.filter(r => !r._hasSbom).length;
const coveredCount = repoRows.filter(r => r._hasSbom).length;
const integratingCount = repoRows.filter(r => r._integrating).length;
```
# Repos
@@ -109,6 +120,11 @@ display(html`<div class="kpi-row">
<h3>Domains</h3>
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
</div>
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
<h3>Integrating</h3>
<p class="big-num">${integratingCount}</p>
<small>${integratingCount === 0 ? "✓ All repos integrated" : `${integratingCount} onboarding`}</small>
</div>
<div class="card ${coveredCount < repoRows.length ? '' : ''}">
<h3>SBOM Ingested</h3>
<p class="big-num">${coveredCount} / ${repoRows.length}</p>
@@ -160,13 +176,17 @@ if (domainBlocks.length === 0) {
<table class="repo-table">
<thead><tr>
<th>Repo</th>
<th>Status</th>
<th>SBOM</th>
<th>Packages</th>
<th>Local path</th>
</tr></thead>
<tbody>
${rows.map(r => html`<tr class="${r._hasSbom ? '' : 'row-gap'}">
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
<td class="repo-cell"><code>${r.repo}</code></td>
<td>${r._integrating
? html`<span class="chip chip-integrating">⚙ integrating</span>`
: html`<span class="chip chip-ok">ready</span>`}</td>
<td class="${r._hasSbom ? 'sbom-ok' : 'sbom-warn'}">${r.sbom}</td>
<td>${r.pkgs}</td>
<td class="path-cell">${r.path}</td>
@@ -197,31 +217,56 @@ const filteredRows = repoRows.filter(r =>
display(Inputs.table(filteredRows.map(r => ({
Repo: r.repo,
Domain: r.domain,
Status: r.status,
SBOM: r.sbom,
Pkgs: r.pkgs,
"EPs (domain)": r.eps || "—",
"TDs (domain)": r.tds || "—",
Path: r.path,
})), {maxWidth: 1000}));
})), {maxWidth: 1100}));
```
## How to Ingest a Repo
## Onboard a New Repo
```js
display(html`<div class="howto">
<h4>Register a new repo</h4>
<pre>cd ~/the-custodian/state-hub
make add-repo DOMAIN=&lt;slug&gt; SLUG=&lt;repo-slug&gt; NAME="Display Name" PATH=/absolute/path</pre>
const _h2onboard = [...document.querySelectorAll("#observablehq-main h2")]
.find(h => h.textContent.includes("Onboard a New Repo"));
if (_h2onboard) { _h2onboard.style.position = "relative"; withDocHelp(_h2onboard, "/docs/repo-integration"); }
```
<h4>Ingest SBOM (single ecosystem, auto-detect lockfile at root)</h4>
<pre>make ingest-sbom REPO=&lt;slug&gt; REPO_PATH=/absolute/path</pre>
<h4>Ingest SBOM (multi-ecosystem repo — scans all lockfiles recursively)</h4>
<pre>make ingest-sbom REPO=&lt;slug&gt; SCAN=1 REPO_PATH=/absolute/path</pre>
<h4>Infra-only repos (Ansible/shell — no lockfile)</h4>
<p>Register the repo for inventory purposes. SBOM gap is expected and intentional.
Terraform providers are tracked via <code>.terraform.lock.hcl</code> (auto-detected by <code>--scan</code>).</p>
```js
display(html`<div class="onboard-panel">
<div class="onboard-step">
<span class="onboard-num">1</span>
<div>
<strong>Clone the repo locally</strong>
<pre>git clone &lt;remote-url&gt; /path/to/repo</pre>
</div>
</div>
<div class="onboard-step">
<span class="onboard-num">2</span>
<div>
<strong>Register from the repo root</strong>
<pre>cd /path/to/repo
custodian register-project --domain &lt;slug&gt;</pre>
<p class="onboard-note">The custodian writes <code>CLAUDE.custodian.md</code>, registers the repo, and creates 4 onboarding tasks in the domain's topic.</p>
</div>
</div>
<div class="onboard-step">
<span class="onboard-num">3</span>
<div>
<strong>Open the repo in Claude Code</strong>
<pre>cd /path/to/repo && claude</pre>
<p class="onboard-note">The repo agent sees the Repo Integration workstream at session start and integrates autonomously — no manual interaction needed.</p>
</div>
</div>
<div class="onboard-step">
<span class="onboard-num">4</span>
<div>
<strong>Monitor here</strong>
<p class="onboard-note">The <strong>⚙ integrating</strong> badge clears when the repo agent completes all 4 onboarding tasks and closes the workstream.</p>
</div>
</div>
</div>`);
```
@@ -253,9 +298,15 @@ make add-repo DOMAIN=&lt;slug&gt; SLUG=&lt;repo-slug&gt; NAME="Display Name" PAT
.sbom-warn { color: #856404; font-weight: 600; }
.path-cell { font-family: monospace; font-size: 0.78rem; color: gray; }
.howto { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem 1.25rem; margin-top: 0.5rem; }
.howto h4 { margin: 0.75rem 0 0.3rem; font-size: 0.9rem; }
.howto h4:first-child { margin-top: 0; }
.howto pre { background: var(--theme-background); border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.82rem; overflow-x: auto; margin: 0 0 0.5rem; }
.howto p { font-size: 0.85rem; color: gray; margin: 0 0 0.5rem; }
.card-integrating { border: 2px solid #7c3aed; }
.chip-integrating { background: #ede9fe; color: #5b21b6; }
.row-integrating { background: #faf5ff; }
.onboard-panel { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; background: var(--theme-background-alt); border-radius: 8px; overflow: hidden; }
.onboard-step { display: flex; gap: 1rem; align-items: flex-start; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
.onboard-step:last-child { border-bottom: none; }
.onboard-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--theme-foreground-focus, #1a1a1a); color: var(--theme-background, white); font-size: 0.8rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.onboard-step strong { font-size: 0.9rem; display: block; margin-bottom: 0.3rem; }
.onboard-step pre { background: var(--theme-background); border-radius: 4px; padding: 0.4rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0 0 0.35rem; }
.onboard-note { font-size: 0.82rem; color: var(--theme-foreground-muted, gray); margin: 0; line-height: 1.45; }
</style>