État du process nexus/SBFB (sprint, phase, review, Codex, rounds, git, CI) en barre de statut, bandeau et panneau /nexus, plus une garde de fin de tour.

Publish apps. Distribute them peer-to-peer. Verify what can be verified. Keep the limits visible.
SBFB is currently in closed pilot. The core protocol and several developer tools are code-backed, but this is not yet a public production network.
SBFB is a protocol and local runtime for publishing, distributing, and opening web applications over a peer-to-peer network. A publisher can package a web app (plain HTML, React, Pyodide, WASM, JupyterLite, or any build that produces static web files), attach a manifest, sign provenance, and announce the app. Other nodes discover the announcement, fetch the content-addressed archive, and open it inside a browser sandbox served by their own local daemon.
The second axis is shared AI/GPU compute. Apps can request analysis, translation, or generation tasks from volunteer CPU/GPU workers, but only under explicit local consent, per-project resource caps, and signed task/result records. The worker path exists as primitives and pilot tooling; public multi-worker scale and anti-cheat are still validation work.
The design goal is simple: no app store, no cloud account, no central authority deciding what may exist, and no hidden trust claim. Every public claim should map to code, a local proof, or a clearly marked future layer.
| Area | Status | What this means |
|---|---|---|
| Local daemon, shell UI, iframe sandbox, bridge | Code-backed | A node can run a local shell, open sandboxed apps, and broker app requests through postMessage. |
| P2P app distribution | Code-backed / pilot | iroh gossip, blobs, docs, feed sync, and blob cache exist. LAN/WAN pilot validation is narrower than a public production network. |
| Source provenance | Code-backed self-attestation | provenance.json and Ed25519 signatures exist. This is not a third-party audit or independent reproducible build. |
| Factory CLI | Code-backed | create, validate, preview, and publish exist. The automated publish path currently covers a subset of the full FG0-FG10 gate model. |
| Factory Viewer / Operator | Code-backed local tools | Read-only viewer and local privileged operator exist for pilot/dev workflows. |
| Local search and Proof Cards | Code-backed local features | SQLite FTS5 and deterministic Proof Cards exist. Cross-node SearchManifest discovery is future work. |
| Curator primitives | Code-backed primitives | Signed vouch/disendorse/feed operations exist. Full governance timelines, dissent UI, and social process are future work. |
| Worker compute | Code-backed primitives / pilot | Consent, caps, worker signatures, task/result records, Ollama backend, optional llama.cpp, and kudos v1 exist. Public worker quorum and exact model/GPU proof are not complete. |
| Installers | Built and internally validated | Windows NSIS, Linux .deb, and macOS .dmg tooling exists. Closed pilot distribution uses maintainer-provided binaries and hashes until public release assets are finalized. |
| Gate 1 / CHATONS pilot | In progress | The pilot protocol exists for 2-3 testers. 24h stability, public release assets, and external feedback still determine the next gate. |
Not ready today: production CHATONS hosting, sensitive user data, public multi-node worker quorum, independent security audit, third-party reproducible builds, SearchManifest network discovery, and a full governance UI.
+------------------+
| P2P Network |
| iroh gossip |
| iroh-blobs |
| iroh-docs |
+--------+---------+
|
QUIC / Ed25519
|
+--------+---------+
| Shell Daemon |
| Rust binary |
| local HTTP |
| bearer + Host |
| + Origin auth |
+--------+---------+
|
default: 127.0.0.1
|
+-------------------+-------------------+
| |
+----------+----------+ +--------------+---------+
| Shell UI (React) | | Blob-serve route |
| Browse, Curators, | | decompresses app zip |
| Network, Deploy | | LRU cache + CSP |
+----------+----------+ +--------------+---------+
| |
| postMessage bridge |
+---------------+-----------------------+
|
+----------+----------+
| App iframe |
| sandbox="allow- |
| scripts" |
| no allow-same- |
| origin |
| CSP connect-src |
| 'none' |
+---------------------+
Blob-served app content is protected by the browser sandbox and CSP. It is not currently a separate TCP origin; the iframe gets an opaque origin because allow-same-origin is deliberately absent.
provenance.json self-attestation with Ed25519 signatures and source metadata. This proves a signed local publication path, not that an independent third party rebuilt the same archive.sandbox="allow-scripts" and no allow-same-origin; untrusted content receives CSP including connect-src 'none'.sbfb-bridge.js and window.postMessage with UUID correlation. The host validates requests against a global method allowlist and source iframe. Per-app runtime enforcement of declared SBFB.json bridge.methods is still a hardening item.sbfb-ideas, use iroh-docs replication; other app storage may remain local until explicitly wired..deb, and macOS .dmg. Public asset distribution is separate from the source tree.1. Create -- sbfb-factory create scaffolds an app with SBFB.json, index.html, a template lock, and the bridge SDK.
2. Develop -- The app can be plain static HTML or built from any toolchain that outputs browser files. sbfb-factory validate checks the manifest and basic workspace safety.
3. Preview -- sbfb-factory preview zips the local project, sends it to the local daemon preview store, and returns a blob-serve URL with a 30-minute TTL.
4. Publish -- sbfb-factory publish --repo-url ... talks to the local daemon, runs the currently automated publish checks, deploys from the source repository, and verifies the returned provenance. The full FG0-FG10 model is documented as the target process; the code path today automates FG4, FG5, FG6, deploy/publish, and FG8, with separate commands for preview and checks.
5. Distribute -- Other nodes discover the app through feed/gossip material, fetch the archive through iroh-blobs, verify the available hashes/signatures, and cache it locally.
6. Open -- Users open the app in the shell UI. The daemon serves app files through blob-serve into a sandboxed iframe. The app can only reach SBFB capabilities through the bridge methods exposed by the host.
Closed pilot users receive binaries and hashes directly from the maintainer. Public release asset links will be added when the pilot boundary is lifted.
From source:
# Build the launcher and daemon.
cargo build -p nexus-launcher -p nexus-shell-daemon --release
# Run the launcher (on Windows use target/release/nexus-launcher.exe).
./target/release/nexus-launcher
The launcher/daemon create local identity and auth material under the SBFB home directory (~/.sbfb by default, or SBFB_HOME when set). Some launcher and worker state uses platform data directories or .nexus-grid paths.
The daemon binds to loopback by default and authenticated HTTP routes require a 256-bit bearer token plus Host/Origin checks. The config comments require loopback; non-loopback binds should be treated as a security bug, not a supported production mode.
The Factory CLI is part of the source tree. It is not currently one of the binary artifacts attached by the release workflow, so build it from source for pilot work:
cargo build -p sbfb-factory --release
# Or install it into Cargo's bin directory.
cargo install --path crates/sbfb-factory
Example:
# Scaffold a static app.
sbfb-factory create myapp
cd myapp/
# Edit index.html, then validate.
sbfb-factory validate .
# Preview in the daemon sandbox.
sbfb-factory preview .
# Publish from a public source repository.
sbfb-factory publish . --repo-url https://github.com/<you>/<repo>
Factory gate status:
docs/factory/FACTORY_GATES.md.Every app carries a SBFB.json manifest:
{
"schema_version": 2,
"name": "my-app",
"display_name": "My App",
"description": "What this app does",
"category": "tools",
"license": "AGPL-3.0-or-later",
"lang": "en",
"bridge": {
"methods": ["storage_get", "storage_set", "identity_pubkey"]
},
"tech": {
"type": "static-html",
"build_command": null
}
}
The Rust parser is forward-compatible: unknown fields are ignored and older or newer schema versions can be accepted. That is intentional for network evolution, but it also means README examples may include fields that are documented for the protocol before every field is semantically enforced by the current runtime.
| App | Description | Bridge methods used |
|---|---|---|
Protocol Explorer (examples/sbfb-explorer/) | Interactive protocol documentation with live network status and provenance demos. Pure HTML/JS. | node_status, identity_pubkey, browse_list, provenance_verify |
Ideas Hub (examples/sbfb-ideas/) | Decentralized idea board with voting. This is the clearest current example of replicated app storage. Pure HTML/JS. | storage_get, storage_set, storage_list, storage_delete, storage_version (live-sync), identity_pubkey |
Factory Viewer (examples/sbfb-factory-viewer/) | Read-only sandboxed app for browsing network apps with search and Proof Cards. | browse_list, search, proof_card_get, storage_get |
These are demonstration apps. They prove the bridge and sandbox model, but they are not a complete app marketplace.
Apps in iframes communicate with the host through sbfb-bridge.js, a small dependency-free JavaScript library:
<script src="/sbfb-bridge.js"></script>
<script>
const bridge = new SBFBBridge({ timeout: 10000 });
// Storage. Replication depends on the app namespace and daemon wiring.
await bridge.setStorage("my-key", { value: 42 });
const data = await bridge.getStorage("my-key");
const all = await bridge.listStorage("prefix/");
await bridge.deleteStorage("old-key");
// Network introspection.
const status = await bridge.getNodeStatus();
const apps = await bridge.getBrowseList();
// Provenance and evidence views.
const proof = await bridge.verifyRelease(projectId);
const card = await bridge.getProofCard(projectId);
// Local full-text search.
const results = await bridge.search("translation app", { limit: 20 });
// Identity.
const me = await bridge.getIdentityPubkey();
// Compute task submission. Payload shape follows the coordinator task API
// and pilot apps should avoid sending personal data.
const redacted = await bridge.piiRedact(userInput);
await bridge.submitTask({ prompt: redacted.redacted_text });
// Poll-based live storage notifications.
const unsubscribe = bridge.onStorageUpdate("my-app", () => refresh());
</script>
Every request has a UUID correlation ID and timeout. The host validates message shape and iframe source before dispatch. The bridge surface is broader than the current manifest parser in a few places; keep SBFB.json bridge.methods as the declared intent, not as proof that every runtime call is per-app gated today.
A node can contribute GPU or CPU compute through nexus-worker. The current worker stack supports:
llm_llama_cpp feature and a valid GGUF model path.Important limits:
model_digest, logprobs_hash, and output_token_ids are useful signals, not a complete anti-cheat proof. Today, model_digest can be derived from the model name and logprobs_hash can be empty/zero depending on backend.The threat model follows STRIDE + LINDDUN. Current defense layers:
sandbox="allow-scripts" without allow-same-origin; app JavaScript cannot access shell DOM, cookies, or localStorage.connect-src 'none' and sandbox directives.Additional protections include Hashcash anti-spam, GCRA rate limiting, age witness primitives, iframe watchdog pings, and launcher/keystore paths for encrypted key material. Direct daemon paths may still rely on user-only file permissions for some secrets, so key encryption at rest should not be described as universal until every launch path is covered.
Blob-serve is intentionally reachable without the normal bearer header because iframe navigation cannot attach custom headers. The compensating controls are content addressing, blob tickets where available, path traversal rejection, zip validation, iframe sandboxing, and CSP.
Full threat model: docs/security/THREAT_MODEL.md
Six cumulative trust levels describe what a node can currently know about an app. They are evidence labels, not endorsements.
| Level | Label | What it means |
|---|---|---|
| N0 | Upload direct | An archive exists on the network. Origin unknown. |
| N1 | Source readable | A public source repository is declared. |
| N2 | Provenance attested | A local Ed25519 self-attestation links source metadata to an artifact hash. |
| N3 | Signature verified | The local daemon verified the provenance signature and proof-chain fields it has. |
| N4 | Reproducible build | A third party rebuilt from source and got the same hash. Future. |
| N5 | Feed verified | The observed publication history is hash-chain integral. Global completeness still depends on network sync and cross-peer validation. |
Details: docs/trust/TRUST_TAXONOMY.md
The repo now separates protocol primitives from product experiments:
@dev/@web modes are future work.sbfb/
+-- Cargo.toml # Rust workspace
+-- crates/
| +-- nexus-core-rs/ # iroh wrapper, crypto, canonical bytes
| +-- nexus-coordinator-rs/ # DB, dispatcher, validator, kudos, search
| +-- nexus-shell-daemon-core/ # shared daemon auth/config/blob helpers
| +-- nexus-shell-daemon/ # local daemon: HTTP, blob-serve, feed, gossip
| +-- nexus-launcher/ # launcher: spawn daemon, token, browser
| +-- nexus-worker-core/ # worker engine, consent, LLM backends
| +-- nexus-worker/ # worker binary
| +-- nexus-events-core/ # security event writers
| +-- nexus-executor/ # build executor experiments
| +-- nexus-trace-core/ # OpenTelemetry tracing helpers
| +-- nexus-test-harness/ # integration test helpers
| +-- sbfb-manifest/ # SBFB.json parser shared by daemon/factory
| +-- sbfb-factory/ # Factory CLI/process/operator server
+-- web/ # Shell UI (React + TypeScript + Vite)
+-- tools/
| +-- factory-operator/ # Local control-center front (greenfield S80 Phase B: React 19 + Tailwind v4 oklch + Base UI)
+-- examples/
| +-- sbfb-explorer/ # Protocol Explorer app
| +-- sbfb-ideas/ # Ideas Hub app
| +-- sbfb-factory-viewer/ # Factory Viewer app
+-- docs/
| +-- security/THREAT_MODEL.md # STRIDE + LINDDUN threat model
| +-- trust/TRUST_TAXONOMY.md # N0-N5 trust levels
| +-- factory/FACTORY_GATES.md # FG0-FG10 target gate model
| +-- release/GATE1_TEST_PROTOCOL.md # Closed pilot procedures
+-- prompts/agent/ # Portable agent prompts
+-- .planning/ # Sprint plans, verification, research
| Layer | Technology |
|---|---|
| Protocol / daemon | Rust 2021, iroh 0.98, iroh-blobs 0.100, iroh-docs |
| Crypto | Ed25519, BLAKE3, ChaCha20-Poly1305 for QUIC, Argon2id, AES-256-GCM, FROST primitives |
| Storage | SQLite, local app storage, selected iroh-docs replicated namespaces |
| Search | SQLite FTS5 with BM25 ranking |
| LLM inference | Ollama by default; llama.cpp behind feature flags |
| Frontend | React 19, TypeScript, Tailwind CSS, shadcn/ui, Zustand, React Query, Vite |
| CI | Self-hosted Woodpecker (ci.sbfb.world) plus GitHub Actions |
| Installers | cargo-packager config for NSIS, .deb, AppImage, and .dmg |
Latest repo-recorded verification:
Core commands:
# Rust format, lint, and tests.
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run --workspace --locked
cargo test --workspace --locked --doc
# Release builds.
cargo build -p nexus-shell-daemon --release
cargo build -p sbfb-factory --release
# Frontend.
cd web
npm install
npm run lint
npx tsc --noEmit -p tsconfig.app.json
npm run test:unit
npm run build
npm run size
The SBFB protocol, daemon, worker, and tooling are licensed under AGPL-3.0-or-later. Modifications to these network services must keep source available under the license.
Apps published on the SBFB network are not automatically AGPL. Each app carries its own license. To be recommended as a higher-trust app, it should publish source, declare a license, and provide verifiable provenance.
SBFB is in closed pilot phase.
The v1.0 tag exists on the configured Git remotes. Public release assets, hashes, and tester instructions are still a pilot boundary, not a broad public distribution guarantee.
Works today:
hooks/register.tsx 464 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { NexusHistory, NexusSnapshot, PhaseRow } from '../types'
5import {
6 PROFILE_GOLDEN,
7 phaseCommits,
8 phaseRows,
9 planPhases,
10 sprintLog,
11 sprintPart,
12 alerts,
13 checklist,
14 ciSummary,
15 claimsDone,
16 codexStatus,
17 currentPhase,
18 goldenErrors,
19 goldenSources,
20 isGoldenSource,
21 isReviewPass,
22 maxRound,
23 maxSprint,
24 mutationMarkers,
25 porcelainPaths,
26 preflightVerdict,
27 residueFiles,
28 reviewVerdict,
29 statusLine,
30 stopProblems,
31 untrackedCode,
32} from './lib'
33
34type $ = EngineInterface
35
36const PANE = 'nexus-phase'
37const PANE_TITLE = 'nexus — phase'
38const snapshot = atom({ plugin: 'nexus-process', key: 'snapshot' } as const, null)
39const lightcheck = atom({ plugin: 'nexus-process', key: 'lightcheck' } as const, null)
40const history = atom({ plugin: 'nexus-process', key: 'history' } as const, null)
41
42const CI_TTL_MS = 10 * 60 * 1000
43const REFRESH_MS = 60 * 1000
44const DEBOUNCE_MS = 2000
45
46// État du module : repart de zéro à chaque rechargement, ce qui est voulu
47// (la racine est redétectée, le cache CI vidé).
48const ctx: {
49 configuredRoot: string
50 nextPlan: string
51 root: string | null | undefined
52 ci: { at: number; json: string | null }
53 inFlight: Promise<NexusSnapshot | null> | null
54 pending: { cancel: () => void } | null
55} = { configuredRoot: '', nextPlan: '', root: undefined, ci: { at: 0, json: null }, inFlight: null, pending: null }
56
57async function readOrNull($: $, path: string): Promise<string | null> {
58 try {
59 return await $.fs.read(path)
60 } catch {
61 return null
62 }
63}
64
65async function resolveRoot($: $): Promise<string | null> {
66 if (ctx.root !== undefined) return ctx.root
67 const candidates: string[] = []
68 if (ctx.configuredRoot) candidates.push(ctx.configuredRoot)
69 try {
70 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd(), timeoutMs: 5000 })
71 if (top.exitCode === 0) candidates.push(top.stdout.trim())
72 } catch {
73 // pas de git : seule la config compte
74 }
75 ctx.root = null
76 for (const c of candidates) {
77 const base = c.replace(/\\/g, '/').replace(/\/$/, '')
78 if (await $.fs.exists(`${base}/scripts/agent/agentctl.py`)) {
79 ctx.root = base
80 break
81 }
82 }
83 return ctx.root
84}
85
86async function git($: $, root: string, args: readonly string[]): Promise<string> {
87 try {
88 const out = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 20_000 })
89 return out.exitCode === 0 ? out.stdout : ''
90 } catch {
91 return ''
92 }
93}
94
95async function ciJson($: $, root: string): Promise<string | null> {
96 const now = await $.clock.now()
97 if (now - ctx.ci.at < CI_TTL_MS) return ctx.ci.json
98 try {
99 const out = await $.process.run(
100 ['gh', 'run', 'list', '--branch', 'master', '--limit', '12', '--json', 'name,workflowName,status,conclusion'],
101 { cwd: root, timeoutMs: 15_000 },
102 )
103 ctx.ci = { at: now, json: out.exitCode === 0 ? out.stdout : null }
104 } catch {
105 ctx.ci = { at: now, json: null }
106 }
107 return ctx.ci.json
108}
109
110async function collect($: $): Promise<NexusSnapshot | null> {
111 const root = await resolveRoot($)
112 if (root === null) return null
113 const active = `${root}/.planning/active`
114 const entries = await $.fs.list(active).catch(() => [])
115 const sprint = maxSprint(entries.map(e => e.name))
116 const phase = sprint === null ? null : currentPhase(entries, sprint)
117 const prefix = sprint !== null && phase !== null ? `${active}/sprint${sprint}_phase_${phase}` : null
118 const [preflight, review, codex, roundNames] = await Promise.all([
119 prefix ? readOrNull($, `${prefix}_preflight.md`) : null,
120 prefix ? readOrNull($, `${prefix}_review.md`) : null,
121 prefix ? readOrNull($, `${prefix}_codex_review.md`) : null,
122 $.fs.list(`${active}/rounds`).then(l => l.map(e => e.name)).catch(() => [] as string[]),
123 ])
124
125 const [status, codeStatus, staged, added, worktreeDiff, golden, aheadOut, ciRaw] = await Promise.all([
126 git($, root, ['status', '--porcelain=v1']),
127 git($, root, ['status', '--porcelain=v1', '--untracked-files=all', '--', 'crates', 'web/src']),
128 git($, root, ['diff', '--cached', '--name-only']),
129 git($, root, ['diff', '--cached', '--name-only', '--diff-filter=A']),
130 git($, root, ['diff', 'HEAD', '-U0', '--', 'crates', 'web/src']),
131 readOrNull($, `${root}/${PROFILE_GOLDEN}`),
132 git($, root, ['rev-list', '--count', '@{upstream}..HEAD']),
133 ciJson($, root),
134 ])
135
136 const untracked = untrackedCode(codeStatus)
137 const ciInfo = ciSummary(ciRaw)
138 const aheadNum = Number.parseInt(aheadOut.trim(), 10)
139
140 return {
141 root,
142 sprint,
143 phase,
144 preflightVerdict: preflightVerdict(preflight),
145 reviewVerdict: reviewVerdict(review),
146 isReviewPass: isReviewPass(review),
147 codex: codexStatus(codex),
148 rounds: sprint !== null && phase !== null ? maxRound(roundNames, sprint, phase) : 0,
149 dirty: porcelainPaths(status).length,
150 untrackedCode: untracked,
151 residueFiles: residueFiles([...added.split('\n').filter(Boolean), ...untracked]),
152 mutationMarkers: mutationMarkers(worktreeDiff),
153 goldenErrors: goldenErrors(staged.split('\n').filter(Boolean), goldenSources(golden)),
154 ahead: Number.isNaN(aheadNum) ? null : aheadNum,
155 ci: ciInfo.ci,
156 ciFailed: ciInfo.failed,
157 updatedAt: await $.clock.now(),
158 }
159}
160
161async function expandHome($: $, path: string): Promise<string> {
162 if (!path.startsWith('~')) return path
163 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
164 return `${home.replace(/\\/g, '/')}${path.slice(1)}`
165}
166
167/** Plan du sprint suivant : `.planning/sprint{N}_plan.md` (précédent S78), `active/`, puis l'option nextSprintPlan. */
168async function nextSprintPlan($: $, root: string, sprint: number): Promise<{ source: string; text: string } | null> {
169 const candidates = [`${root}/.planning/sprint${sprint}_plan.md`, `${root}/.planning/active/sprint${sprint}_plan.md`]
170 if (ctx.nextPlan) candidates.push(await expandHome($, ctx.nextPlan))
171 for (const path of candidates) {
172 const text = await readOrNull($, path)
173 if (text !== null && planPhases(sprintPart(text, sprint)).length > 0) return { source: path, text: sprintPart(text, sprint) }
174 }
175 return null
176}
177
178async function collectHistory($: $, snap: NexusSnapshot): Promise<NexusHistory> {
179 const root = snap.root
180 const sprint = snap.sprint
181 const [log, plan, sprintLogMd] = await Promise.all([
182 sprint === null ? '' : git($, root, ['log', '--format=%h|%ad|%s', '--date=short', `--grep=Sprint ${sprint} Phase`]),
183 sprint === null ? null : readOrNull($, `${root}/.planning/active/sprint${sprint}_plan.md`),
184 readOrNull($, `${root}/docs/claude/SPRINT_LOG.md`),
185 ])
186 const phases: PhaseRow[] = sprint === null || plan === null ? [] : phaseRows(planPhases(plan), phaseCommits(log, sprint), snap.phase)
187 const nextPlan = sprint === null ? null : await nextSprintPlan($, root, sprint + 1)
188 return {
189 sprint,
190 phases,
191 next:
192 sprint === null || nextPlan === null
193 ? null
194 : { sprint: sprint + 1, source: nextPlan.source, phases: phaseRows(planPhases(nextPlan.text), [], null) },
195 sprints: sprintLog(sprintLogMd ?? ''),
196 }
197}
198
199async function doRefresh($: $): Promise<NexusSnapshot | null> {
200 const snap = await collect($)
201 await update($, snapshot, () => snap)
202 if (snap !== null) {
203 const hist = await collectHistory($, snap)
204 await update($, history, () => hist)
205 }
206 $.ui.status(snap === null ? undefined : statusLine(snap))
207 return snap
208}
209
210/** Un seul rafraîchissement à la fois ; un appel concurrent attend celui en cours. */
211async function refresh($: $): Promise<NexusSnapshot | null> {
212 if (ctx.inFlight === null) {
213 ctx.inFlight = doRefresh($).finally(() => {
214 ctx.inFlight = null
215 })
216 }
217 return ctx.inFlight
218}
219
220function refreshSoon($: $): void {
221 ctx.pending?.cancel()
222 ctx.pending = $.clock.after(DEBOUNCE_MS, () => {
223 ctx.pending = null
224 void refresh($)
225 })
226}
227
228async function agentctl($: $, root: string, args: readonly string[], timeoutMs: number): Promise<ProcessRunResult> {
229 const script = `${root}/scripts/agent/agentctl.py`
230 for (const head of [['python'], ['py', '-3'], ['python3']]) {
231 try {
232 return await $.process.run([...head, script, ...args], { cwd: root, timeoutMs })
233 } catch {
234 // interpréteur absent : on essaie le suivant
235 }
236 }
237 return {
238 exitCode: 127,
239 stdout: '',
240 stderr: 'aucun interpréteur Python trouvé (python, py -3, python3)',
241 isStdoutTruncated: false,
242 isStderrTruncated: false,
243 }
244}
245
246async function runLightcheck($: $): Promise<string> {
247 const root = await resolveRoot($)
248 if (root === null) return 'Pas dans le repo nexus.'
249 const out = await agentctl($, root, ['precommit-lightcheck', '--scope', 'staged'], 120_000)
250 const lines = `${out.stdout}\n${out.stderr}`.split('\n').filter(l => /BLOCK|WARN|OK|PASS|error/i.test(l))
251 const text = `lightcheck (staged) → exit ${out.exitCode}\n${lines.slice(0, 30).join('\n') || '(aucune ligne BLOCK/WARN)'}`
252 await update($, lightcheck, () => text)
253 return text
254}
255
256async function codexPromptPath($: $): Promise<string> {
257 const root = await resolveRoot($)
258 const snap = (await read($, snapshot)) ?? (await refresh($))
259 if (root === null) return 'Pas dans le repo nexus.'
260 if (snap === null || snap.sprint === null || snap.phase === null) return 'Sprint ou phase introuvable.'
261 const out = await agentctl(
262 $,
263 root,
264 ['codex-prompt-path', '--sprint', String(snap.sprint), '--phase', snap.phase, '--absolute'],
265 20_000,
266 )
267 const path = out.stdout.trim()
268 if (out.exitCode !== 0 || !path) return `codex-prompt-path a échoué : ${out.stderr.trim()}`
269 const copied = await $.ui.copy({ text: path })
270 return `${path}${copied.isCopied ? ' (copié)' : ''}`
271}
272
273function sprintText(snap: NexusSnapshot): string {
274 return [
275 statusLine(snap),
276 ...checklist(snap).map(c => `${c.isOk ? '✓' : '✗'} ${c.label} : ${c.detail}`),
277 `fichiers sales : ${snap.dirty}${snap.untrackedCode.length ? ` (dont ${snap.untrackedCode.length} ?? sous crates/ ou web/src)` : ''}`,
278 ...alerts(snap).map(a => `⚠ ${a}`),
279 ].join('\n')
280}
281
282async function openPane($: $): Promise<void> {
283 await $.ui.open({ id: PANE, title: PANE_TITLE })
284}
285
286export const register: Register = (on, options) => {
287 ctx.configuredRoot = typeof options.nexusRoot === 'string' ? options.nexusRoot.trim() : ''
288 ctx.nextPlan = typeof options.nextSprintPlan === 'string' ? options.nextSprintPlan.trim() : ''
289
290 on('session.start', async ($, e, next) => {
291 const started = await next(e)
292 if ((await resolveRoot($)) === null) return started
293 await $.command.register({ name: 'nexus', description: 'Ouvre le panneau de la phase nexus en cours' })
294 await $.command.register({ name: 'nexus-sprint', description: "Résumé de l'état du process nexus" })
295 await $.command.register({ name: 'nexus-lightcheck', description: 'Lance agentctl precommit-lightcheck --scope staged' })
296 await $.command.register({ name: 'nexus-codex', description: 'Chemin du prompt Codex de la phase (copié)' })
297 void refresh($)
298 $.clock.every(REFRESH_MS, () => void refresh($))
299 return started
300 })
301
302 on('command.run', { command: 'nexus' }, async $ => {
303 await refresh($)
304 await openPane($)
305 return { text: 'Panneau nexus ouvert.' }
306 })
307
308 on('command.run', { command: 'nexus-sprint' }, async $ => {
309 const snap = await refresh($)
310 if (snap === null) return { text: "Pas dans le repo nexus (réglez l'option nexusRoot)." }
311 const hist = await read($, history)
312 const done = hist?.phases.filter(p => p.status === 'fait').length ?? 0
313 const next = hist?.next ? `S${hist.next.sprint} prévu : ${hist.next.phases.map(p => p.id).join(', ')}` : 'prochain sprint : aucun plan'
314 return {
315 text: `${sprintText(snap)}\nphases S${snap.sprint} : ${done}/${hist?.phases.length ?? 0} faites · ${next} · historique : ${hist?.sprints.length ?? 0} sprints`,
316 }
317 })
318
319 on('command.run', { command: 'nexus-lightcheck' }, async $ => ({ text: await runLightcheck($) }))
320
321 on('command.run', { command: 'nexus-codex' }, async $ => ({ text: await codexPromptPath($) }))
322
323 on('tool.call', async ($, e, next) => {
324 const ran = await next(e)
325 const root = ctx.root
326 if (root === null || root === undefined) return ran
327 if (e.tool === 'Edit' || e.tool === 'Write') {
328 const sources = goldenSources(await readOrNull($, `${root}/${PROFILE_GOLDEN}`))
329 if (isGoldenSource(e.file_path, root, sources)) {
330 $.ui.toast('nexus : source du golden factory_profile modifiée — rafraîchir le golden dans ce commit (check 11)')
331 }
332 }
333 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'Bash') refreshSoon($)
334 return ran
335 }).catch(($, e, next) => next(e))
336
337 on('classic.Stop', async ($, e, next) => {
338 const result = await next(e)
339 if (result.block !== undefined || e.stop_hook_active || (await resolveRoot($)) === null) return result
340 const snap = await refresh($)
341 if (snap === null) return result
342 const problems = stopProblems(snap)
343 if (problems.length === 0) return result
344 if (claimsDone(e.last_assistant_message)) {
345 return {
346 ...result,
347 block:
348 `[nexus-process] Fin annoncée mais l'arbre n'est pas propre :\n- ${problems.join('\n- ')}\n` +
349 'Corrige (revert de la mutation, suppression de la sonde, golden rafraîchi) avant de conclure.',
350 }
351 }
352 $.ui.toast(`nexus : ${problems[0]}`)
353 return result
354 }).catch(($, e, next) => next(e))
355
356 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
357 const snap = await read($, snapshot)
358 if (snap === null || e.props.hasSurvey) return next(e)
359 const list = alerts(snap)
360 if (list.length === 0) return next(e)
361 const { Box, Button, Text } = $.ui.resolve(e)
362 return (
363 <Box flexDirection="column">
364 {list.slice(0, 4).map(a => (
365 <Text color="yellow">⚠ nexus · {a}</Text>
366 ))}
367 <Button key="open" label="Panneau" onPress={() => openPane($)} />
368 </Box>
369 )
370 })
371
372 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
373 const { Box, Button, Text } = $.ui.resolve(e)
374 const snap = await read($, snapshot)
375 const hist = await read($, history)
376 const check = await read($, lightcheck)
377 if (snap === null) {
378 return <Text dimColor>Pas de repo nexus détecté (option nexusRoot).</Text>
379 }
380 const phase = (snap.phase ?? '').toUpperCase()
381 const width = Math.max(30, e.props.bodyColumns - 2)
382 const fit = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
383 const mark = { fait: '✓', 'en-cours': '▶', prévu: '·' } as const
384 const tone = { fait: 'green', 'en-cours': 'yellow', prévu: undefined } as const
385 const done = hist?.phases.filter(p => p.status === 'fait').length ?? 0
386 const phaseLine = (p: PhaseRow) => {
387 const tail = p.commit ? ` ${p.date} ${p.commit}` : ''
388 return fit(`${mark[p.status]} ${p.id.padEnd(3)} ${p.title}`, width - tail.length) + tail
389 }
390 let version = ''
391 return (
392 <Box flexDirection="column">
393 <Text bold>État</Text>
394 <Text>{statusLine(snap)}</Text>
395 {checklist(snap).map(c => (
396 <Text color={c.isOk ? 'green' : 'red'}>
397 {c.isOk ? '✓' : '✗'} {c.label} <Text dimColor>{c.detail}</Text>
398 </Text>
399 ))}
400 <Text>
401 {' '}sales {snap.dirty} · ?? code {snap.untrackedCode.length} · ↑{snap.ahead ?? '?'} · CI {snap.ci}
402 </Text>
403 {alerts(snap).map(a => (
404 <Text color="yellow">⚠ {a}</Text>
405 ))}
406 <Box flexWrap="wrap">
407 <Button key="lightcheck" label="lightcheck" hotkey="l" onPress={() => runLightcheck($)} />
408 <Button key="codex" label="prompt Codex" hotkey="c" onPress={async () => $.ui.toast(await codexPromptPath($))} />
409 <Button
410 key="preflight"
411 label="preflight"
412 hotkey="p"
413 onPress={() => $.prompt.fill({ text: `Lance le preflight G8 de S${snap.sprint} phase ${phase} selon le canon.` })}
414 />
415 <Button key="refresh" label="rafraîchir" hotkey="r" onPress={() => refresh($)} />
416 </Box>
417 {check !== null && <Text dimColor>{check}</Text>}
418
419 <Text> </Text>
420 <Text bold>
421 Phases S{snap.sprint} — {done}/{hist?.phases.length ?? 0} faites
422 </Text>
423 {(hist?.phases.length ?? 0) === 0 && <Text dimColor>Plan du sprint introuvable.</Text>}
424 {hist?.phases.map(p => (
425 <Text color={tone[p.status]} dimColor={p.status === 'prévu'}>
426 {phaseLine(p)}
427 </Text>
428 ))}
429
430 <Text> </Text>
431 <Text bold>Prochain sprint{hist?.next ? ` S${hist.next.sprint}` : ''} — prévu</Text>
432 {hist?.next == null ? (
433 <Text dimColor>Aucun plan trouvé (.planning/sprint{(snap.sprint ?? 0) + 1}_plan.md ou option nextSprintPlan).</Text>
434 ) : (
435 <Box flexDirection="column">
436 <Text dimColor>{fit(hist.next.source, width)}</Text>
437 {hist.next.phases.map(p => (
438 <Box flexDirection="column">
439 <Text>{fit(`· Phase ${p.id} — ${p.title}`, width)}</Text>
440 {p.children.map(c => (
441 <Text dimColor>{fit(` ${c.id}. ${c.title}`, width)}</Text>
442 ))}
443 </Box>
444 ))}
445 </Box>
446 )}
447
448 <Text> </Text>
449 <Text bold>Historique — {hist?.sprints.length ?? 0} sprints</Text>
450 {hist?.sprints.map(s => {
451 const header = s.version !== version
452 version = s.version
453 return (
454 <Box flexDirection="column">
455 {header && <Text color="cyan">{s.version}</Text>}
456 <Text dimColor={!/OPEN/.test(s.state)}>{fit(` S${String(s.sprint).padEnd(3)} ${s.state}`, width)}</Text>
457 </Box>
458 )
459 })}
460 </Box>
461 )
462 })
463}
464hooks/lib.ts 311 lines1// Fonctions pures : aucune I/O, testables sous `claude plugin test`.
2// Les motifs reprennent scripts/agent/agentctl.py et le Check 0bis de
3// .claude/hooks/phase-precommit-lightcheck.sh du repo nexus.
4
5import type { CiStatus, CodexStatus, NexusSnapshot } from '../types'
6
7export type Entry = { name: string; mtimeMs: number }
8
9// agentctl.py:35 FINAL_PASS_RE
10const FINAL_PASS = /^## Verdict\s*:\s*PASS\s*$/m
11const PREFLIGHT_VERDICT = /\b(EXECUTE|PLAN-ADAPT|SCOPE-CUT-CONSISTENT|DESIGN-CONFLICT)\b/
12// Check 0bis : fichiers-artefacts et marqueurs de mutation
13const RESIDUE_FILE = /(^|\/)(zz_)|probe|_scratch|\.bak$/i
14const MUTATION_MARKER = /MUTATION[-: ]|MUTATION$|TEMPORARY review probe/
15const CODE_ROOT = /^(crates|web\/src)\//
16
17export function maxSprint(names: readonly string[]): number | null {
18 let best: number | null = null
19 for (const name of names) {
20 const m = /^sprint(\d+)_/.exec(name)
21 if (m) best = Math.max(best ?? 0, Number(m[1]))
22 }
23 return best
24}
25
26/** Phase dont le preflight est le plus récent (agentctl ne détecte jamais la phase). */
27export function currentPhase(entries: readonly Entry[], sprint: number): string | null {
28 const re = new RegExp(`^sprint${sprint}_phase_([a-z0-9]+)_preflight\\.md$`, 'i')
29 let best: { phase: string; mtimeMs: number } | null = null
30 for (const e of entries) {
31 const m = re.exec(e.name)
32 if (m && (best === null || e.mtimeMs > best.mtimeMs)) best = { phase: m[1]!, mtimeMs: e.mtimeMs }
33 }
34 return best?.phase ?? null
35}
36
37export function preflightVerdict(text: string | null): string | null {
38 if (text === null) return null
39 return PREFLIGHT_VERDICT.exec(text)?.[1] ?? 'présent'
40}
41
42/** Miroir de review_gate_errors (agentctl.py:711) : PASS exact, pas PASS-PENDING, Codex non en attente. */
43export function isReviewPass(text: string | null): boolean {
44 if (text === null || !FINAL_PASS.test(text)) return false
45 const verdict = text.split('\n').find(l => l.startsWith('## Verdict')) ?? ''
46 if (verdict.includes('PASS-PENDING')) return false
47 return !/Codex.*EN ATTENTE|EN ATTENTE.*Codex|Ready for Codex verification/i.test(text)
48}
49
50export function reviewVerdict(text: string | null): string | null {
51 if (text === null) return null
52 const line = text.split('\n').find(l => l.startsWith('## Verdict'))
53 if (line === undefined) return 'sans verdict'
54 return line.replace(/^## Verdict\s*:?\s*/, '').trim() || 'sans verdict'
55}
56
57/** Marqueurs de codex_review_errors (agentctl.py:570). */
58export function codexStatus(text: string | null): CodexStatus {
59 if (text === null || !text.trim()) return 'absent'
60 if (/Statut\s*:\s*GAP|Gaps?\s*:\s*[1-9]/i.test(text)) return 'gap'
61 if (/Statut\s*:\s*PARTIEL|Partiels?\s*:\s*[1-9]/i.test(text)) return 'partiel'
62 // `\b` de JS ignore les lettres accentuées (Python les compte) : limites Unicode explicites.
63 if (/(?<![\p{L}\d_])(CONFIRME|CONFIRM[EÉ]|GAP|PARTIEL|PARTIAL|CONFIRMED)(?![\p{L}\d_])|\*\*(PASS|FAIL)/iu.test(text)) return 'ok'
64 return 'sans-verdict'
65}
66
67export function maxRound(names: readonly string[], sprint: number, phase: string): number {
68 const re = new RegExp(`^sprint${sprint}_phase_${phase}_.*_r(\\d+)\\.md$`, 'i')
69 let best = 0
70 for (const name of names) {
71 const m = re.exec(name)
72 if (m) best = Math.max(best, Number(m[1]))
73 }
74 return best
75}
76
77export function porcelainPaths(porcelain: string): { code: string; path: string }[] {
78 return porcelain
79 .split('\n')
80 .filter(l => l.length > 3)
81 .map(l => ({ code: l.slice(0, 2), path: l.slice(3).replace(/^"|"$/g, '') }))
82}
83
84export function untrackedCode(porcelain: string): string[] {
85 return porcelainPaths(porcelain)
86 .filter(p => p.code === '??' && CODE_ROOT.test(p.path))
87 .map(p => p.path)
88}
89
90export function residueFiles(paths: readonly string[]): string[] {
91 return paths.filter(p => RESIDUE_FILE.test(p))
92}
93
94export function mutationMarkers(diff: string): string[] {
95 return diff
96 .split('\n')
97 .filter(l => l.startsWith('+') && !l.startsWith('+++') && MUTATION_MARKER.test(l))
98 .map(l => l.slice(1).trim())
99}
100
101export function goldenSources(goldenJson: string | null): string[] {
102 if (goldenJson === null) return []
103 try {
104 const golden = JSON.parse(goldenJson) as { sources?: unknown }
105 if (!Array.isArray(golden.sources)) return []
106 return golden.sources.flatMap(s =>
107 s !== null && typeof s === 'object' && typeof (s as { path?: unknown }).path === 'string'
108 ? [(s as { path: string }).path]
109 : [],
110 )
111 } catch {
112 return []
113 }
114}
115
116export const PROFILE_GOLDEN = 'crates/sbfb-workflow-core/src/factory_profile.golden.json'
117
118/** Miroir de profile_golden_errors (agentctl.py:634) / Check 11. */
119export function goldenErrors(staged: readonly string[], sources: readonly string[]): string[] {
120 if (staged.includes(PROFILE_GOLDEN)) return []
121 const touched = sources.filter(s => staged.includes(s))
122 return touched.length === 0
123 ? []
124 : [`source(s) du golden factory_profile stagée(s) sans le golden : ${touched.join(', ')}`]
125}
126
127export function isGoldenSource(path: string, root: string, sources: readonly string[]): boolean {
128 const norm = path.replace(/\\/g, '/')
129 const base = root.replace(/\\/g, '/').replace(/\/$/, '')
130 const rel = norm.toLowerCase().startsWith(base.toLowerCase() + '/') ? norm.slice(base.length + 1) : norm
131 return sources.includes(rel)
132}
133
134type GhRun = { name?: string; workflowName?: string; status?: string; conclusion?: string }
135
136/** Dernier run par workflow (gh run list --json name,workflowName,status,conclusion). */
137export function ciSummary(json: string | null): { ci: CiStatus; failed: string[] } {
138 if (json === null) return { ci: 'inconnu', failed: [] }
139 let runs: GhRun[]
140 try {
141 runs = JSON.parse(json) as GhRun[]
142 } catch {
143 return { ci: 'inconnu', failed: [] }
144 }
145 if (!Array.isArray(runs) || runs.length === 0) return { ci: 'inconnu', failed: [] }
146 const latest = new Map<string, GhRun>()
147 for (const run of runs) {
148 const key = run.workflowName ?? run.name ?? '?'
149 if (!latest.has(key)) latest.set(key, run)
150 }
151 const failed = [...latest]
152 .filter(([, r]) => r.conclusion === 'failure' || r.conclusion === 'startup_failure' || r.conclusion === 'timed_out')
153 .map(([k]) => k)
154 if (failed.length > 0) return { ci: 'rouge', failed }
155 if ([...latest.values()].some(r => r.status !== 'completed')) return { ci: 'en-cours', failed }
156 return { ci: 'vert', failed }
157}
158
159/** La réponse finale annonce-t-elle une fin de travail ? */
160export function claimsDone(text: string | undefined): boolean {
161 if (text === undefined) return false
162 return /(?<![\p{L}\d_])(termin[ée]e?s?|finie?s?|achev[ée]e?s?|prête? à (commit|être commit)\w*|ready to commit|tout est (vert|bon|ok)|all (green|done)|done|## Verdict\s*:?\s*PASS)(?![\p{L}\d_])/iu.test(
163 text,
164 )
165}
166
167const MARK = { ok: '✓', ko: '✗' }
168
169function codexMark(c: CodexStatus): string {
170 return c === 'ok' ? MARK.ok : c === 'absent' ? MARK.ko : c
171}
172
173function ciMark(c: CiStatus): string {
174 return c === 'vert' ? MARK.ok : c === 'rouge' ? MARK.ko : c === 'en-cours' ? '…' : '?'
175}
176
177export function statusLine(s: NexusSnapshot): string {
178 const head = `S${s.sprint ?? '?'}·${(s.phase ?? '?').toUpperCase()}`
179 return [
180 head,
181 `rev ${s.isReviewPass ? MARK.ok : MARK.ko}`,
182 `codex ${codexMark(s.codex)}`,
183 `r${s.rounds}`,
184 `±${s.dirty}`,
185 `↑${s.ahead ?? '?'}`,
186 `CI ${ciMark(s.ci)}`,
187 ].join(' │ ')
188}
189
190export function alerts(s: NexusSnapshot): string[] {
191 const out: string[] = []
192 if (s.mutationMarkers.length > 0) out.push(`marqueur de mutation dans crates/ ou web/src (${s.mutationMarkers.length})`)
193 if (s.residueFiles.length > 0) out.push(`artefact de review : ${s.residueFiles.slice(0, 3).join(', ')}`)
194 out.push(...s.goldenErrors)
195 if (s.ci === 'rouge') out.push(`CI rouge : ${s.ciFailed.slice(0, 4).join(', ')}`)
196 if ((s.ahead ?? 0) >= 10) out.push(`${s.ahead} commits non poussés`)
197 return out
198}
199
200export type Check = { label: string; isOk: boolean; detail: string }
201
202export function checklist(s: NexusSnapshot): Check[] {
203 return [
204 { label: 'preflight G8', isOk: s.preflightVerdict !== null && s.preflightVerdict !== 'DESIGN-CONFLICT', detail: s.preflightVerdict ?? 'absent' },
205 { label: 'review', isOk: s.isReviewPass, detail: s.reviewVerdict ?? 'absent' },
206 { label: 'Codex brut', isOk: s.codex === 'ok', detail: s.codex },
207 { label: 'golden factory_profile', isOk: s.goldenErrors.length === 0, detail: s.goldenErrors.length === 0 ? 'cohérent' : 'à rafraîchir' },
208 { label: 'résidus de review', isOk: s.mutationMarkers.length + s.residueFiles.length === 0, detail: `${s.mutationMarkers.length + s.residueFiles.length}` },
209 { label: 'rounds de review', isOk: s.rounds <= 3, detail: `${s.rounds}` },
210 ]
211}
212
213/** Problèmes qui justifient de bloquer une fin de tour annoncée comme terminée. */
214export function stopProblems(s: NexusSnapshot): string[] {
215 const out: string[] = []
216 if (s.mutationMarkers.length > 0) out.push(`mutation(s) de review laissée(s) dans le code : ${s.mutationMarkers.slice(0, 3).join(' | ')}`)
217 if (s.residueFiles.length > 0) out.push(`fichier(s) sonde/scratch : ${s.residueFiles.slice(0, 5).join(', ')}`)
218 out.push(...s.goldenErrors)
219 return out
220}
221
222// ---- Historique et sprints prévus ----
223
224export type PlanPhase = { id: string; title: string; isMarkedDone: boolean; children: { id: string; title: string }[] }
225
226function cleanTitle(title: string): string {
227 return title
228 .replace(/\*\*|`/g, '')
229 .replace(/\s*\(≈[^)]*\)\s*$/, '')
230 .replace(/\s*[:.]\s*$/, '')
231 .trim()
232}
233
234/** Garde la partie d'un plan multi-livrables qui parle du sprint visé (`# Partie … S84 …`). */
235export function sprintPart(md: string, sprint: number): string {
236 const lines = md.split('\n')
237 const start = lines.findIndex(l => /^# /.test(l) && new RegExp(`\\bS${sprint}\\b`).test(l))
238 if (start < 0) return md
239 const end = lines.findIndex((l, i) => i > start && /^# /.test(l))
240 return lines.slice(start, end < 0 ? undefined : end).join('\n')
241}
242
243/** `## Phase X — titre` (+ sous-étapes `**0a. titre**` sous la phase). */
244export function planPhases(md: string): PlanPhase[] {
245 const out: PlanPhase[] = []
246 for (const line of md.split('\n')) {
247 const phase = /^## Phase ([A-Z0-9]+)\s*[—–-]\s*(.+)$/.exec(line)
248 if (phase) {
249 out.push({
250 id: phase[1]!,
251 title: cleanTitle(phase[2]!),
252 // marqueur explicite après deux-points (« : JOUÉE le … ») ; un « LIVRÉ » dans le titre ne compte pas
253 isMarkedDone: /:\s*(JOU[ÉE]E|DONE|LIVR[ÉE]E?)(?![\p{L}])/iu.test(phase[2]!),
254 children: [],
255 })
256 continue
257 }
258 const step = /^\*\*(\d+[a-z])\.\s+(.+?)\*\*/.exec(line)
259 if (step && out.length > 0) out[out.length - 1]!.children.push({ id: step[1]!, title: cleanTitle(step[2]!) })
260 }
261 return out
262}
263
264export type PhaseCommit = { sha: string; date: string; phase: string }
265
266/** Lignes `sha|date|sujet` de git log ; seuls les commits de phase comptent (pas chore). */
267export function phaseCommits(log: string, sprint: number): PhaseCommit[] {
268 const re = new RegExp(`^(feat|fix|docs|test|refactor)\\([^)]*\\):\\s*Sprint\\s+${sprint}\\s+Phase\\s+([A-Z]+[0-9]?)\\b`)
269 const out: PhaseCommit[] = []
270 for (const line of log.split('\n')) {
271 const [sha, date, ...rest] = line.split('|')
272 const m = re.exec(rest.join('|'))
273 if (sha && date && m) out.push({ sha, date, phase: m[2]! })
274 }
275 return out
276}
277
278export function phaseRows(
279 phases: readonly PlanPhase[],
280 commits: readonly PhaseCommit[],
281 current: string | null,
282): { id: string; title: string; status: 'fait' | 'en-cours' | 'prévu'; commit: string | null; date: string | null; children: { id: string; title: string }[] }[] {
283 return phases.map(p => {
284 // git log est du plus récent au plus ancien : le dernier trouvé est le premier commit de la phase
285 const hits = commits.filter(c => c.phase.toLowerCase() === p.id.toLowerCase())
286 const first = hits[hits.length - 1]
287 const isCurrent = current !== null && current.toLowerCase() === p.id.toLowerCase()
288 const status = first !== undefined || p.isMarkedDone ? 'fait' : isCurrent ? 'en-cours' : 'prévu'
289 return { id: p.id, title: p.title, status, commit: first?.sha.slice(0, 8) ?? null, date: first?.date ?? null, children: p.children }
290 })
291}
292
293/** Tableaux `| N | État | …` de docs/claude/SPRINT_LOG.md, groupés par `## vX — …`. */
294export function sprintLog(md: string): { version: string; sprint: number; state: string }[] {
295 const out: { version: string; sprint: number; state: string }[] = []
296 let version = ''
297 for (const line of md.split('\n')) {
298 const head = /^## (v[\d.]+)\b/.exec(line)
299 if (head) {
300 version = head[1]!
301 continue
302 }
303 const row = /^\|\s*(\d+)\s*\|\s*([^|]*)\|/.exec(line)
304 if (row && version) {
305 const state = row[2]!.replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
306 out.push({ version, sprint: Number(row[1]), state: state.length > 110 ? `${state.slice(0, 109)}…` : state })
307 }
308 }
309 return out
310}
311types/index.d.ts 52 lines1export type CodexStatus = 'absent' | 'ok' | 'gap' | 'partiel' | 'sans-verdict'
2
3export type CiStatus = 'vert' | 'rouge' | 'en-cours' | 'inconnu'
4
5export type NexusSnapshot = {
6 root: string
7 sprint: number | null
8 phase: string | null
9 preflightVerdict: string | null
10 reviewVerdict: string | null
11 isReviewPass: boolean
12 codex: CodexStatus
13 rounds: number
14 dirty: number
15 untrackedCode: string[]
16 residueFiles: string[]
17 mutationMarkers: string[]
18 goldenErrors: string[]
19 ahead: number | null
20 ci: CiStatus
21 ciFailed: string[]
22 updatedAt: number
23}
24
25export type PhaseRow = {
26 id: string
27 title: string
28 status: 'fait' | 'en-cours' | 'prévu'
29 commit: string | null
30 date: string | null
31 children: { id: string; title: string }[]
32}
33
34export type SprintRow = { version: string; sprint: number; state: string }
35
36export type NexusHistory = {
37 sprint: number | null
38 phases: PhaseRow[]
39 next: { sprint: number; source: string; phases: PhaseRow[] } | null
40 sprints: SprintRow[]
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'nexus-process': {
46 snapshot: NexusSnapshot | null
47 history: NexusHistory | null
48 lightcheck: string | null
49 }
50 }
51}
52