SLOPSHOPPER

nexus-process

É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.

newpanebandguardcommandtoast
★ 2v0.1.0NOASSERTIONupdated 2026-10-19SBFB50/SBFB/.claude/skills/nexus-process
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nexus-process
│ ┃ nexus-phase ✕ › fix the failing auth test and add an audit log call │ ┃ Pas de repo nexus détecté (option │ ┃ nexusRoot). ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · nexus-phase
Pas de repo nexus détecté (option nexusRoot).
README

SBFB -- Decentralized P2P App Distribution and Compute

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.

What Is SBFB?

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.

Current Maturity

AreaStatusWhat this means
Local daemon, shell UI, iframe sandbox, bridgeCode-backedA node can run a local shell, open sandboxed apps, and broker app requests through postMessage.
P2P app distributionCode-backed / pilotiroh gossip, blobs, docs, feed sync, and blob cache exist. LAN/WAN pilot validation is narrower than a public production network.
Source provenanceCode-backed self-attestationprovenance.json and Ed25519 signatures exist. This is not a third-party audit or independent reproducible build.
Factory CLICode-backedcreate, validate, preview, and publish exist. The automated publish path currently covers a subset of the full FG0-FG10 gate model.
Factory Viewer / OperatorCode-backed local toolsRead-only viewer and local privileged operator exist for pilot/dev workflows.
Local search and Proof CardsCode-backed local featuresSQLite FTS5 and deterministic Proof Cards exist. Cross-node SearchManifest discovery is future work.
Curator primitivesCode-backed primitivesSigned vouch/disendorse/feed operations exist. Full governance timelines, dissent UI, and social process are future work.
Worker computeCode-backed primitives / pilotConsent, 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.
InstallersBuilt and internally validatedWindows 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 pilotIn progressThe 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.

Architecture

                          +------------------+
                          |  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.

Key Features

  • Peer-to-peer distribution -- app archives propagate through gossip and iroh-blobs as BLAKE3-addressed content. No central host is required for the archive itself.
  • Source-verifiable publish path -- deploy-from-repo writes a 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.
  • Browser sandbox -- app iframes run with sandbox="allow-scripts" and no allow-same-origin; untrusted content receives CSP including connect-src 'none'.
  • Bridge API -- apps use 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.
  • Curator lists instead of central moderation -- communities can sign recommendations or warnings. Users choose curators; no curator can delete an archive from the network.
  • App storage -- the storage bridge supports app namespaces. Some namespaces, currently including sbfb-ideas, use iroh-docs replication; other app storage may remain local until explicitly wired.
  • Local full-text search -- SQLite FTS5 indexes local browse/feed material and returns BM25-ranked results. SearchManifest-based cross-node discovery is future work.
  • Proof Cards -- local deterministic evidence scores (0-100) summarize provenance, feed, curator, license, and freshness signals. A Proof Card is an explanation surface, not a signed security certificate.
  • Distributed compute primitives -- workers enforce 4-level consent, watts/VRAM/hour caps, task signatures, result signatures, and kudos credit. Public worker quorum and exact model/GPU proof are not complete.
  • Kudos v1 -- compute/task contributions use a non-monetary, non-transferable ledger with logarithmic returns, EMA decay, BLAKE3 hash-chain, and fairness metrics. Multi-family kudos and governance weighting are design work.
  • Append-only public feed -- feed entries are Ed25519-signed and BLAKE3 hash-chained per author. Nodes verify the history they have observed; global completeness across all peers remains a network/pilot question.
  • Key rotation -- Ed25519 rotation announcements exist with a 7-day default transition window; other windows can be encoded by the announcement.
  • Cross-platform packaging -- cargo-packager config exists for Windows NSIS, Linux .deb, and macOS .dmg. Public asset distribution is separate from the source tree.
  • AGPL-3.0-or-later -- protocol, daemon, worker, and tooling remain free software. Apps published on the network keep their own licenses.

How It Works

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.

Quick Start

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.

Publishing An App

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:

  • Spec/process: FG0-FG10 are documented in docs/factory/FACTORY_GATES.md.
  • Automated today: manifest pre-validation plus FG4 diff, FG5 sandbox/path checks, FG6 secret/dependency scan, deploy/publish, and FG8 provenance check.
  • Separate command: FG7 preview readiness can be run through the preview check.
  • Future/process: FG0-FG3 classification/scope/template/manifest depth and FG10 curator review are not a single fully enforced publish pipeline yet.

SBFB.json Manifest

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.

Example Apps

AppDescriptionBridge 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.

Bridge SDK

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.

GPU And CPU Compute

A node can contribute GPU or CPU compute through nexus-worker. The current worker stack supports:

  • Ollama over HTTP as the default backend.
  • Optional embedded llama.cpp when built with the llm_llama_cpp feature and a valid GGUF model path.
  • Optional GPU ephemeral behavior such as VRAM wipe when the relevant feature and runtime prerequisites are enabled.
  • 4 consent levels:
  • L1 -- my projects only (default).
  • L2 -- projects marked open-source/provenance-present.
  • L3 -- manual allowlist.
  • L4 -- all public projects.
  • Per-project caps on watts, VRAM, and daily hours.
  • Signed task, claim, and result records.
  • Kudos credit after validation.

Important limits:

  • Workers see task prompts in clear text. Apps should redact PII before submission.
  • The validator checks signatures, task state, and quorum paths where enabled; it does not yet prove that a specific model file ran on a specific GPU.
  • 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.
  • SHA256 quorum exists as a foundation for build/redundancy paths; public multi-worker quorum at scale is still a pilot/future item.

Security Model

The threat model follows STRIDE + LINDDUN. Current defense layers:

  1. Iframe sandbox -- sandbox="allow-scripts" without allow-same-origin; app JavaScript cannot access shell DOM, cookies, or localStorage.
  2. Content Security Policy -- untrusted app content receives strict CSP, including connect-src 'none' and sandbox directives.
  3. postMessage bridge -- UUID correlation, typed request schema, global method allowlist, and iframe source validation.
  4. Loopback HTTP auth -- 256-bit bearer token, Host allowlist, and Origin checks for browser/HTTP calls.
  5. Local IPC auth -- Unix SO_PEERCRED and Windows Named Pipe DACLs are used for local non-browser clients when those transports are available.
  6. Provenance and feed integrity -- Ed25519 signatures, BLAKE3 content hashes, and append-only hash-chain feed entries.

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

Trust Levels

Six cumulative trust levels describe what a node can currently know about an app. They are evidence labels, not endorsements.

LevelLabelWhat it means
N0Upload directAn archive exists on the network. Origin unknown.
N1Source readableA public source repository is declared.
N2Provenance attestedA local Ed25519 self-attestation links source metadata to an artifact hash.
N3Signature verifiedThe local daemon verified the provenance signature and proof-chain fields it has.
N4Reproducible buildA third party rebuilt from source and got the same hash. Future.
N5Feed verifiedThe observed publication history is hash-chain integral. Global completeness still depends on network sync and cross-peer validation.

Details: docs/trust/TRUST_TAXONOMY.md

Factory, RRV, And Babel

The repo now separates protocol primitives from product experiments:

  • Factory is the app creation/publish toolchain. It has a CLI, read-only Factory Viewer app, local privileged Factory Operator, manifest generation, preview, secret scanning, sandbox checks, and provenance hooks.
  • RRV means "Recherche Reseau Verifiable": a search/explanation layer that can inspect protocol facts, app metadata, proofs, and eventually source/code evidence. The local FTS5 and Proof Card pieces exist; SearchManifest and broader @dev/@web modes are future work.
  • Babel is the intended dogfood/canary app for Factory and RRV. It is an application built on the protocol, not part of the protocol itself. The MVP should be a static reader with public-domain fixtures before any live translation network is treated as mature.

Project Structure

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

Tech Stack

LayerTechnology
Protocol / daemonRust 2021, iroh 0.98, iroh-blobs 0.100, iroh-docs
CryptoEd25519, BLAKE3, ChaCha20-Poly1305 for QUIC, Argon2id, AES-256-GCM, FROST primitives
StorageSQLite, local app storage, selected iroh-docs replicated namespaces
SearchSQLite FTS5 with BM25 ranking
LLM inferenceOllama by default; llama.cpp behind feature flags
FrontendReact 19, TypeScript, Tailwind CSS, shadcn/ui, Zustand, React Query, Vite
CISelf-hosted Woodpecker (ci.sbfb.world) plus GitHub Actions
Installerscargo-packager config for NSIS, .deb, AppImage, and .dmg

Tests

Latest repo-recorded verification:

  • 1486 Rust tests in the workspace nextest suite.
  • 279 Vitest tests for frontend, bridge, and API schemas.
  • 6/6 size-limit checks.

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

License

AGPL-3.0-or-later

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.

Status

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:

  • Local daemon, launcher, shell UI, iframe sandbox, and bridge.
  • P2P app distribution primitives: gossip, blobs, feed, local cache.
  • Deploy from source with Ed25519 self-attested provenance.
  • Public feed, signed curator operations, endorsement/disendorsement, and subscriptions.
  • Local FTS5 s
Source 3 files
hooks/register.tsx 464 lines
1import { 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}
464
hooks/lib.ts 311 lines
1// 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}
311
types/index.d.ts 52 lines
1export 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