diff --git a/CHANGELOG.md b/CHANGELOG.md index d306502..0ede0f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.26.0] — 2026-06-23 +### Added +- `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais + `git add -A`), auteur CHLOVA, renvoie le SHA. +- `src/autoext/auto-extension.ts` : `AutoExtensionService.propose` — write+doc → + commit+version → enregistre l'asset en need-review (privilégié = BLOQUÉ, aucun + sursis) → alerte `asset_created` (version + commit + doc). N'exécute jamais l'asset. +- Alerte `asset_created`. 3 tests (dépôt git temp). 73 tests, 0 vuln. + ## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension) ### Added - `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact + diff --git a/orchestrator/src/alerts/types.ts b/orchestrator/src/alerts/types.ts index 4d16724..7eef997 100644 --- a/orchestrator/src/alerts/types.ts +++ b/orchestrator/src/alerts/types.ts @@ -37,6 +37,16 @@ export type AlertEvent = blocked: number; provisional: number; items: DigestItem[]; + } + | { + kind: "asset_created"; + assetId: string; + assetType: string; + version: string; + riskTier: string; + status: string; + commit: string; + doc: string; }; export interface AlertSender { diff --git a/orchestrator/src/autoext/auto-extension.ts b/orchestrator/src/autoext/auto-extension.ts new file mode 100644 index 0000000..accb200 --- /dev/null +++ b/orchestrator/src/autoext/auto-extension.ts @@ -0,0 +1,71 @@ +import type { Logger } from "pino"; +import { AssetRepository } from "../gatekeeper/repository.js"; +import { createAsset, type Asset } from "../gatekeeper/assets.js"; +import type { AlertSender } from "../alerts/types.js"; +import { writeArtifact, slugify, type AssetDraft } from "./artifact-writer.js"; +import { GitCommitter } from "./git-committer.js"; + +/** + * Auto-extension (Phase 5) : CHLOVA crée un asset → écrit fichier + doc → + * COMMIT + VERSION → enregistre en need-review → alerte (avec version + commit + + * doc). L'asset n'est PAS exécuté : il attend la review (privilégié = BLOQUÉ). + * + * Désactivable (CHLOVA_AUTOEXT_ENABLED). Le palier vient du draft, jamais d'un + * choix du LLM visant à contourner la review (privilégié → aucun sursis). + */ +export interface ProposeResult { + asset: Asset; + commit: string; + docPath: string; + artifactPath: string; +} + +export class AutoExtensionService { + constructor( + private readonly repo: AssetRepository, + private readonly git: GitCommitter, + private readonly alerts: AlertSender, + private readonly logger: Logger, + private readonly repoRoot: string, + ) {} + + async propose(draft: AssetDraft): Promise { + const id = `asset:${draft.type}:${slugify(draft.name)}:v${draft.version}`; + if (this.repo.get(id)) { + throw new Error(`asset déjà existant: ${id} (bump la version)`); + } + + const { artifactPath, docPath } = await writeArtifact(this.repoRoot, draft); + const commit = await this.git.commitPaths( + [artifactPath, docPath], + `feat(auto): ${draft.type} ${draft.name} v${draft.version} [need-review]`, + ); + + const asset = createAsset({ + id, + type: draft.type, + version: draft.version, + riskTier: draft.riskTier, + commitLink: commit, + docLink: docPath, + }); + this.repo.create(asset); + + await this.alerts.send({ + kind: "asset_created", + assetId: id, + assetType: draft.type, + version: draft.version, + riskTier: draft.riskTier, + status: asset.status, + commit, + doc: docPath, + }); + + this.logger.warn( + { asset: id, commit, status: asset.status }, + "auto-extension : asset créé en need-review", + ); + return { asset, commit, docPath, artifactPath }; + } +} diff --git a/orchestrator/src/autoext/git-committer.ts b/orchestrator/src/autoext/git-committer.ts new file mode 100644 index 0000000..78b1802 --- /dev/null +++ b/orchestrator/src/autoext/git-committer.ts @@ -0,0 +1,47 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +/** + * Commits ciblés pour l'auto-extension (Phase 5). N'ajoute QUE les chemins + * fournis (jamais `git add -A`) et commite dans le dépôt local. Retourne le SHA. + * + * Le commit matérialise « le dépôt fait foi » : un asset auto-créé est versionné + * AVANT d'être passé en need-review (docs/versioning.md). + */ +export class GitCommitter { + constructor( + private readonly repoRoot: string, + private readonly author = "CHLOVA ", + ) {} + + private async git(args: string[]): Promise { + const { stdout } = await exec("git", args, { cwd: this.repoRoot }); + return stdout.trim(); + } + + /** Ajoute les chemins, commite avec le message, renvoie le SHA court. */ + async commitPaths(paths: string[], message: string): Promise { + if (paths.length === 0) throw new Error("aucun chemin à committer"); + await this.git(["add", "--", ...paths]); + const [name, email] = parseAuthor(this.author); + await this.git([ + "-c", + `user.name=${name}`, + "-c", + `user.email=${email}`, + "commit", + "-m", + message, + "--", + ...paths, + ]); + return this.git(["rev-parse", "--short", "HEAD"]); + } +} + +function parseAuthor(author: string): [string, string] { + const m = /^(.*?)\s*<(.+)>$/.exec(author); + return m ? [m[1]!, m[2]!] : [author, "chlova@local"]; +} diff --git a/orchestrator/test/auto-extension.test.ts b/orchestrator/test/auto-extension.test.ts new file mode 100644 index 0000000..4a118c4 --- /dev/null +++ b/orchestrator/test/auto-extension.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, rm, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { GitCommitter } from "../src/autoext/git-committer.js"; +import { AutoExtensionService } from "../src/autoext/auto-extension.js"; +import { createLogger } from "../src/audit/log.js"; +import type { AlertSender, AlertEvent } from "../src/alerts/types.js"; +import type { AssetDraft } from "../src/autoext/artifact-writer.js"; + +const exec = promisify(execFile); +const log = createLogger("silent"); + +class CapturingSender implements AlertSender { + events: AlertEvent[] = []; + async send(e: AlertEvent): Promise { + this.events.push(e); + } +} + +const draft = (over: Partial = {}): AssetDraft => ({ + type: "workflow-n8n", + name: "Backup NC", + version: "1.0.0", + riskTier: "privileged", + summary: "Sauvegarde.", + content: '{"a":1}', + ...over, +}); + +let root: string; +let repo: AssetRepository; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "chlova-git-")); + await exec("git", ["init", "-q"], { cwd: root }); + await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root }); + repo = new AssetRepository(":memory:"); +}); +afterEach(async () => { + repo.close(); + await rm(root, { recursive: true, force: true }); +}); + +function service(alerts: AlertSender): AutoExtensionService { + return new AutoExtensionService(repo, new GitCommitter(root), alerts, log, root); +} + +describe("AutoExtensionService.propose", () => { + it("écrit, commit, enregistre BLOQUÉ (privilégié) et alerte", async () => { + const sender = new CapturingSender(); + const res = await service(sender).propose(draft()); + + expect(res.asset.status).toBe("bloqué"); // privilégié → aucun sursis + expect(res.commit).toMatch(/^[0-9a-f]{7,}$/); + expect(res.asset.commitLink).toBe(res.commit); + expect(res.asset.docLink).toBe(res.docPath); + + // fichiers présents + commités + await expect(access(join(root, res.artifactPath))).resolves.toBeUndefined(); + const { stdout } = await exec("git", ["log", "--oneline"], { cwd: root }); + expect(stdout).toContain("need-review"); + + // asset persistant + alerte + expect(repo.get(res.asset.id)?.status).toBe("bloqué"); + expect(sender.events.map((e) => e.kind)).toContain("asset_created"); + }); + + it("réversible → PROVISOIRE", async () => { + const res = await service(new CapturingSender()).propose(draft({ riskTier: "reversible", name: "RO tool", type: "tool" })); + expect(res.asset.status).toBe("provisoire"); + expect(res.asset.expiresAt).not.toBeNull(); + }); + + it("refuse un doublon (même id)", async () => { + const svc = service(new CapturingSender()); + await svc.propose(draft()); + await expect(svc.propose(draft())).rejects.toThrow(/existant/); + }); +});