diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 22029169..c5aa0f0f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,8 +19,8 @@ ### Relevant ADR(s) -- [ ] ADR-00XX (Worktree Independence and Materialization Architecture) -- [ ] ADR-00XY (Graph-Native Content, Deterministic Materialization, and Workspace Bridge) +- [ ] ADR-0002 (Worktree Independence and Materialization Architecture) +- [ ] ADR-0003 (Graph-Native Content, Deterministic Materialization, and Workspace Bridge) - [ ] None ### Compliance Declaration diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bbd1c3..5ce8d274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **ADR-0004: Content Attachments Belong in git-warp** — Decision record establishing that CAS-backed content-on-node is a git-warp substrate responsibility, not a git-mind domain concern. Aligns with Paper I's `Atom(p)` attachment formalism (#252) +- **Chalk formatting for `extension list`** — `formatExtensionList()` renders extension names in cyan bold, versions dimmed, `[builtin]` in yellow / `[custom]` in magenta, consistent with all other CLI commands (#265) +- **Prefix collision detection** — `registerExtension()` now checks incoming domain prefixes against all registered extensions and throws a descriptive error on overlap. Idempotent re-registration of the same extension name is still allowed (#264) +- **Imperative views declared in extension manifests** — `milestone` and `progress` views added to the roadmap manifest; `traceability`, `coverage`, and `onboarding` views added to the architecture manifest. Purely declarative — makes `extension list` show the full picture (#268) +- **`git mind extension remove ` subcommand** — Removes a custom extension from the registry. Throws on built-in or non-existent extensions. `--json` output supported. `removeExtension()` exported from public API (#263) +- **JSON Schema contracts for extension CLI output** — 4 new schemas in `docs/contracts/cli/`: `extension-list`, `extension-validate`, `extension-add`, `extension-remove`. Valid samples added to the contract test harness (#262) +- **Deferred items documented in ROADMAP** — #261 (ephemeral registration) and #269 (`--extension` flag) documented with rationale and recommended H2 slot + +### Changed + +- **Upgraded `@git-stunts/git-warp`** from v11.3.3 to v11.5.0 +- **`registerBuiltinExtensions()` memoized** — Module-level `builtInsLoaded` flag prevents redundant YAML file reads on repeated invocations within the same process (#266) +- **Test count** — 537 tests across 28 files (was 527) + ## [3.2.0] - 2026-02-17 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index aae67feb..37ce6f0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2175,6 +2175,20 @@ NEXUS ←── ORACLE │ These items are not assigned to a milestone yet. They'll be scheduled based on user feedback and priorities. +### Extension persistence & ephemeral loading (deferred from M12 polish) + +Two issues were filed during the M12 extension polish pass and intentionally deferred: + +- **#261 — Ephemeral registration: extension add doesn't persist across invocations.** + `git mind extension add ` registers for the current process only. The fix requires a persistence mechanism — a lockfile (`.git/git-mind/extensions.yaml`), graph-stored config, or git-config entries. Each option has different tradeoffs for portability, discoverability, and merge semantics. This also changes the CLI boot sequence for ALL commands (startup must load user extensions after built-ins), so it needs careful design. + +- **#269 — `--extension ` flag for single-invocation loading.** + A workaround for #261: load an extension for one command only (`git mind view my-view --extension ./ext.yaml`). Useful for CI/CD pipelines that inject custom domain logic. Deferred because this is cleaner to design after #261's persistence exists — the flag would be "like `extension add` but ephemeral", which is only meaningful once `add` is actually persistent. + +**Recommended slot:** H2 (CONTENT + MATERIALIZATION) planning. Both issues naturally fall into the extension lifecycle story — persistence is a prerequisite for the extension marketplace vision (H4). Design the persistence mechanism during H2 kickoff, implement as the first H2 deliverable so that all subsequent extension work (content system extensions, materializer extensions) benefits from proper registration. + +### Other backlog items + - `git mind onboarding` as a guided walkthrough (not just a view) - Confidence decay over time (edges rot if not refreshed) - View composition (combine multiple views) diff --git a/bin/git-mind.js b/bin/git-mind.js index 4c78c096..59cf9edf 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -5,7 +5,7 @@ * Usage: git mind [options] */ -import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review, diff, set, unsetCmd, extensionList, extensionValidate, extensionAdd } from '../src/cli/commands.js'; +import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review, diff, set, unsetCmd, extensionList, extensionValidate, extensionAdd, extensionRemove } from '../src/cli/commands.js'; import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js'; import { createContext } from '../src/context-envelope.js'; import { registerBuiltinExtensions } from '../src/extension.js'; @@ -94,6 +94,8 @@ Commands: --json Output as JSON add Load and register an extension --json Output as JSON + remove Unregister an extension by name + --json Output as JSON Edge types: implements, augments, relates-to, blocks, belongs-to, consumed-by, depends-on, documents`); @@ -377,7 +379,7 @@ switch (command) { const extFlags = parseFlags(args.slice(2)); switch (subCmd) { case 'list': - await extensionList(cwd, { json: extFlags.json ?? false }); + extensionList(cwd, { json: extFlags.json ?? false }); break; case 'validate': { const validatePath = args.slice(2).find(a => !a.startsWith('--')); @@ -389,9 +391,14 @@ switch (command) { await extensionAdd(cwd, addPath, { json: extFlags.json ?? false }); break; } + case 'remove': { + const removeName = args.slice(2).find(a => !a.startsWith('--')); + extensionRemove(cwd, removeName, { json: extFlags.json ?? false }); + break; + } default: console.error(`Unknown extension subcommand: ${subCmd ?? '(none)'}`); - console.error('Usage: git mind extension '); + console.error('Usage: git mind extension '); process.exitCode = 1; } break; diff --git a/docs/adr/ADR-0004.md b/docs/adr/ADR-0004.md new file mode 100644 index 00000000..6dc75876 --- /dev/null +++ b/docs/adr/ADR-0004.md @@ -0,0 +1,135 @@ +# ADR-0004: Content Attachments Belong in git-warp + +- **Status:** Accepted +- **Date:** 2026-02-20 +- **Deciders:** Git Mind maintainers +- **Related:** ADR-0002, ADR-0003, WARP Paper I (aion-paper-01), M13A VESSEL-CORE roadmap milestone +- **Upstream impact:** git-warp, git-cas + +--- + +## 1. Context + +The M13A (VESSEL-CORE) roadmap milestone calls for a content-on-node system: the ability to attach rich content (documents, specs, narratives) to graph nodes, stored as content-addressed blobs, with full CRDT versioning and time-travel. + +The original plan placed this system entirely in git-mind: a `ContentStore` abstraction, a `_content` property convention, `git mind content set/show` commands, storage policy engine, and provenance receipts. + +On review, the storage primitive — attaching a content-addressed blob to a graph node — is not a domain concern. It is a graph-level operation that belongs in git-warp. + +### 1.1 The Paper Says So + +WARP Paper I defines a WARP graph as `(S, α, β)` where: + +- `S` is a finite directed multigraph (the skeleton) +- `α : V_S → WARP` assigns an **attachment** to every vertex +- `β : E_S → WARP` assigns an **attachment** to every edge + +Attachments are full WARP graphs or **atoms**: `Atom(p)` for some payload `p ∈ P`, where P is "the stuff we are not going to model internally (bytestrings, floats, external object IDs, ...)". + +Currently, git-warp models nodes and edges with flat key-value properties. There is no first-class concept of an attachment payload. Content-addressed blob attachment is the concrete realization of `Atom(p)` — it gives nodes (and potentially edges) the ability to carry opaque payload data, exactly as the paper's formalism requires. + +### 1.2 The Layering Argument + +If content attachment lives in git-warp: + +- **Time-travel works for free.** Content SHAs are properties; `materialize({ ceiling })` already handles property-level time-travel. +- **Multi-writer merge works for free.** CRDT conflict resolution on the SHA property follows existing semantics. +- **Observer scoping works for free.** Content visibility follows node visibility. +- **Provenance is already tracked.** Every property mutation carries tick/writer metadata. +- **Any tool on git-warp gets it.** Not just git-mind — any future consumer of the WARP graph API can attach and retrieve content. + +If content attachment lives in git-mind, all of the above must be re-implemented or worked around at the application layer, duplicating guarantees that already exist in the substrate. + +--- + +## 2. Decision + +**Content attachment is a git-warp responsibility, not a git-mind responsibility.** + +git-warp should: + +1. **Install `git-cas` as a dependency** — providing content-addressed blob storage backed by the git object store. +2. **Expose an API for attaching content to nodes** — this could be as simple as a property convention (e.g., a CAS key stored as a node property), or a dedicated method on the patch/node API. The exact API design is git-warp's decision. +3. **Expose content read/retrieval** — given a node, retrieve its attached content blob. + +git-mind's role becomes a thin CLI/UX layer: + +- `git mind content set ` — CLI command that calls the git-warp attachment API +- `git mind content show ` — CLI command that reads and displays attached content +- `git mind content edit ` — editor integration, crash recovery, conflict handling (M13B) +- Storage policy (MIME thresholds, size limits) — domain-level configuration +- Materialization templates that consume attached content (M14) + +--- + +## 3. Alternatives Considered + +### 3A: Content system entirely in git-mind (original M13A plan) + +git-mind implements `ContentStore` over git plumbing, manages `_content` property convention, handles CAS operations directly. + +**Rejected because:** +- Duplicates CRDT/time-travel/observer guarantees already in git-warp +- Other git-warp consumers cannot benefit +- Thickens the git-mind layer with storage plumbing that isn't domain logic +- Diverges from the paper's formalism, which places attachments at the graph level + +### 3B: Content as plain git-warp properties (no CAS, inline values) + +Store content directly as property values on nodes. + +**Rejected because:** +- Property values are not designed for large payloads (documents, images) +- No deduplication across nodes with identical content +- No streaming/chunking for large files +- Misses the `Atom(p)` semantics — atoms are opaque external references, not inline data + +--- + +## 4. Consequences + +### For git-warp + +- New dependency: `git-cas` +- New API surface: content attachment on nodes (and potentially edges) +- Closer alignment with Paper I's `(S, α, β)` formalism +- git-warp's README/docs should reference the attachment concept + +### For git-mind + +- M13A scope shrinks significantly — git-mind provides CLI/UX, not storage +- M13B (content editing UX) is unaffected — editor integration remains a git-mind concern +- Existing property-based workflows are unaffected +- The `_content` property convention (if that's the API shape) is documented as a git-warp concern, not a git-mind convention + +### For the roadmap + +- M13A splits: upstream work in git-warp (attachment API), then thin git-mind CLI layer +- M14 (FORGE/materialization) benefits — the materialization pipeline reads content via the same git-warp API, no git-mind-specific content abstraction needed +- M16 (CITADEL/trust) benefits — trust-scoped content visibility is just trust-scoped property visibility + +--- + +## 5. Relationship to the Paper + +| Paper concept | git-warp realization | +|---|---| +| `Atom(p)` — opaque payload | CAS blob referenced by SHA | +| `α(v)` — vertex attachment | Content property on node | +| `β(e)` — edge attachment | Content property on edge (future) | +| `P` — set of atomic payloads | Git object store (content-addressed) | +| Depth-0 attachment | A node whose attachment is a single blob | +| Deeper attachments | Future: nested WARP graphs as attachments (not in scope for this decision) | + +This ADR addresses the depth-0 case: nodes carry `Atom(p)` payloads via CAS. The full recursive attachment model (`α(v)` mapping to arbitrary WARP graphs) is a future concern — but this decision establishes the correct layering so that deeper attachments can be added later without architectural rework. + +--- + +## 6. Open Questions (for git-warp) + +These are implementation details for the git-warp maintainers: + +1. **API shape:** Dedicated `node.attach(blob)` / `node.content()` methods, or a property convention like `_content: `? Either works; the former is more discoverable. +2. **Edge attachments:** Should edges also support content attachment from day one, or is node-only sufficient for the first iteration? +3. **Integrity verification:** Should `getContent()` verify the SHA on read, or trust the object store? +4. **MIME / metadata:** Should git-warp store content metadata (MIME type, size, encoding) alongside the CAS reference, or leave that to consumers like git-mind? diff --git a/docs/adr/README.md b/docs/adr/README.md index d6a06fc5..2f4fc8bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,8 +8,8 @@ Use ADRs for decisions that are hard to reverse, cross-cut multiple subsystems, ## ADR Index -## ADR-00XX — Worktree Independence and Materialization Architecture -**Status:** Accepted +## [ADR-0002](./ADR-0002.md) — Worktree Independence and Materialization Architecture +**Status:** Accepted **Date:** 2026-02-15 ### What it establishes @@ -24,8 +24,8 @@ Defines the core separation model: **worktree-aware, never worktree-bound**. --- -## ADR-00XY — Graph-Native Content, Deterministic Materialization, and Workspace Bridge -**Status:** Accepted +## [ADR-0003](./ADR-0003.md) — Graph-Native Content, Deterministic Materialization, and Workspace Bridge +**Status:** Accepted **Date:** 2026-02-15 ### What it adds @@ -41,10 +41,10 @@ Turns the separation model into an adoption-ready product path without breaking --- -## What changed from ADR-00XX to ADR-00XY +## What changed from ADR-0002 to ADR-0003 1. **From principle to execution:** - ADR-00XX defined boundaries; ADR-00XY defines how users actually work within them. + ADR-0002 defined boundaries; ADR-0003 defines how users actually work within them. 2. **Editing UX became first-class:** The project now explicitly treats editing ergonomics as a top adoption risk. @@ -88,6 +88,21 @@ Recommended sections: --- +## [ADR-0004](./ADR-0004.md) — Content Attachments Belong in git-warp +**Status:** Accepted +**Date:** 2026-02-20 + +### What it establishes +- Content-on-node (CAS-backed blob attachment) is a **git-warp** responsibility, not git-mind. +- git-warp should install `git-cas` and expose an API for attaching content-addressed blobs to nodes. +- git-mind provides the CLI/UX layer (`content set/show/edit`) on top. +- This aligns git-warp with Paper I's `(S, α, β)` formalism — nodes carrying `Atom(p)` payloads. + +### Why it matters +Prevents git-mind from duplicating CRDT, time-travel, observer, and provenance guarantees that already exist in the substrate. Makes the attachment primitive available to any git-warp consumer, not just git-mind. + +--- + ## Quick Contribution Rules - Keep ADRs concise but specific. diff --git a/docs/contracts/cli/extension-add.schema.json b/docs/contracts/cli/extension-add.schema.json new file mode 100644 index 00000000..b8192095 --- /dev/null +++ b/docs/contracts/cli/extension-add.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/extension-add.schema.json", + "title": "git-mind extension add --json", + "description": "Extension registration result from `git mind extension add --json`", + "type": "object", + "required": ["schemaVersion", "command", "name", "version", "views", "lenses"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "extension-add" }, + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "views": { + "type": "array", + "items": { "type": "string" } + }, + "lenses": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/docs/contracts/cli/extension-list.schema.json b/docs/contracts/cli/extension-list.schema.json new file mode 100644 index 00000000..b96721b9 --- /dev/null +++ b/docs/contracts/cli/extension-list.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/extension-list.schema.json", + "title": "git-mind extension list --json", + "description": "Registered extensions from `git mind extension list --json`", + "type": "object", + "required": ["schemaVersion", "command", "extensions"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "extension-list" }, + "extensions": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "version", "builtin", "views", "lenses"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "builtin": { "type": "boolean" }, + "views": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "prefixes": { "type": "array", "items": { "type": "string" } }, + "edgeTypes": { "type": "array", "items": { "type": "string" } }, + "requireBothEndpoints": { "type": "boolean" } + } + } + }, + "lenses": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } +} diff --git a/docs/contracts/cli/extension-remove.schema.json b/docs/contracts/cli/extension-remove.schema.json new file mode 100644 index 00000000..65b124a4 --- /dev/null +++ b/docs/contracts/cli/extension-remove.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/extension-remove.schema.json", + "title": "git-mind extension remove --json", + "description": "Extension removal result from `git mind extension remove --json`", + "type": "object", + "required": ["schemaVersion", "command", "name", "version"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "extension-remove" }, + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } +} diff --git a/docs/contracts/cli/extension-validate.schema.json b/docs/contracts/cli/extension-validate.schema.json new file mode 100644 index 00000000..21ce74fd --- /dev/null +++ b/docs/contracts/cli/extension-validate.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/extension-validate.schema.json", + "title": "git-mind extension validate --json", + "description": "Extension manifest validation result from `git mind extension validate --json`", + "type": "object", + "required": ["schemaVersion", "command", "valid", "errors", "record"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "command": { "type": "string", "const": "extension-validate" }, + "valid": { "type": "boolean" }, + "errors": { + "type": "array", + "items": { "type": "string" } + }, + "record": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + } + } +} diff --git a/extensions/architecture/extension.yaml b/extensions/architecture/extension.yaml index 5b0641dc..9f69f136 100644 --- a/extensions/architecture/extension.yaml +++ b/extensions/architecture/extension.yaml @@ -14,5 +14,14 @@ views: prefixes: [crate, module, pkg] edgeTypes: [depends-on] requireBothEndpoints: true + - name: traceability + description: Spec-to-implementation gap analysis + prefixes: [spec, adr] + - name: coverage + description: Code-to-spec coverage gap analysis + prefixes: [crate, module, pkg, spec, adr] + - name: onboarding + description: Topologically-sorted reading order for new engineers + prefixes: [doc, spec, adr] lenses: [critical-path, blocked, parallel] diff --git a/extensions/roadmap/extension.yaml b/extensions/roadmap/extension.yaml index 7c37dad9..36663217 100644 --- a/extensions/roadmap/extension.yaml +++ b/extensions/roadmap/extension.yaml @@ -3,8 +3,6 @@ version: 1.0.0 description: Project roadmap domain — phases, milestones, tasks, and progress tracking domain: - # milestone and feature are domain-owned prefixes but surfaced - # via dedicated built-in views (milestone, progress) rather than here prefixes: [phase, milestone, task, feature] edgeTypes: [belongs-to, blocks, implements] statusValues: [todo, in-progress, done, blocked] @@ -16,5 +14,11 @@ views: - name: backlog description: Task nodes with all related edges prefixes: [task] + - name: milestone + description: Milestone progress — completion stats per milestone + prefixes: [milestone, task, feature] + - name: progress + description: Work-item status breakdown (done/in-progress/todo/blocked) + prefixes: [task, feature] lenses: [incomplete, frontier, critical-path, blocked, parallel] diff --git a/package-lock.json b/package-lock.json index 090ae0c2..b79cd2f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@git-stunts/git-warp": "^11.3.3", + "@git-stunts/git-warp": "^11.5.0", "@git-stunts/plumbing": "^2.8.0", "ajv": "^8.17.1", "chalk": "^5.3.0", @@ -764,9 +764,9 @@ } }, "node_modules/@git-stunts/git-warp": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-11.3.3.tgz", - "integrity": "sha512-7RqRr5wjJJYaZsijJ9PKlgCMu8jzRkODzhdPs5LzsRHi+PY7KbBiRniTEcm5hiL/SQtoMTuZ6KiiKuBI7V3jiA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-11.5.0.tgz", + "integrity": "sha512-IzgAU8FEmd+W/OoawfPNciVS7GpqjSVagkUEv1Cc6HT24PdmeipNfufL1WgS+XeTz8L+kEdnjGzBG/dhMrhNEg==", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", diff --git a/package.json b/package.json index 6882ef75..0793154c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "format": "prettier --write 'src/**/*.js' 'bin/**/*.js'" }, "dependencies": { - "@git-stunts/git-warp": "^11.3.3", + "@git-stunts/git-warp": "^11.5.0", "@git-stunts/plumbing": "^2.8.0", "ajv": "^8.17.1", "chalk": "^5.3.0", diff --git a/src/cli/commands.js b/src/cli/commands.js index 418b61e9..2cff8c00 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -24,8 +24,8 @@ import { generateSuggestions } from '../suggest.js'; import { getPendingSuggestions, acceptSuggestion, rejectSuggestion, skipSuggestion, batchDecision } from '../review.js'; import { computeDiff } from '../diff.js'; import { createContext, DEFAULT_CONTEXT } from '../context-envelope.js'; -import { loadExtension, registerExtension, listExtensions, validateExtension } from '../extension.js'; -import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff } from './format.js'; +import { loadExtension, registerExtension, removeExtension, listExtensions, validateExtension } from '../extension.js'; +import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff, formatExtensionList } from './format.js'; /** * Write structured JSON to stdout with schemaVersion and command fields. @@ -817,24 +817,23 @@ export async function diff(cwd, refA, refB, opts = {}) { export function extensionList(_cwd, opts = {}) { const extensions = listExtensions(); if (opts.json) { - outputJson('extension-list', { extensions }); + outputJson('extension-list', { + extensions: extensions.map(ext => ({ + name: ext.name, + version: ext.version, + description: ext.description, + builtin: ext.builtin, + views: ext.views, + lenses: ext.lenses, + })), + }); return; } if (extensions.length === 0) { console.log(info('No extensions registered.')); return; } - for (const ext of extensions) { - const tag = ext.builtin ? '[builtin]' : '[custom]'; - console.log(`${ext.name} ${ext.version} ${tag}`); - if (ext.description) console.log(` ${ext.description}`); - if (ext.views.length > 0) { - console.log(` views: ${ext.views.map(v => v.name).join(', ')}`); - } - if (ext.lenses.length > 0) { - console.log(` lenses: ${ext.lenses.join(', ')}`); - } - } + console.log(formatExtensionList(extensions)); } /** @@ -898,3 +897,31 @@ export async function extensionAdd(_cwd, manifestPath, opts = {}) { process.exitCode = 1; } } + +/** + * Remove a registered extension by name. + * @param {string} _cwd + * @param {string} name + * @param {{ json?: boolean }} opts + */ +export function extensionRemove(_cwd, name, opts = {}) { + if (!name) { + console.error(error('Usage: git mind extension remove ')); + process.exitCode = 1; + return; + } + try { + const record = removeExtension(name); + if (opts.json) { + outputJson('extension-remove', { + name: record.name, + version: record.version, + }); + } else { + console.log(success(`Removed extension: ${record.name} v${record.version}`)); + } + } catch (err) { + console.error(error(err.message)); + process.exitCode = 1; + } +} diff --git a/src/cli/format.js b/src/cli/format.js index 4ce0a98a..b9025b78 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -498,6 +498,34 @@ export function formatProgressMeta(meta) { return lines.join('\n'); } +/** + * Format the extension list for terminal display. + * @param {import('../extension.js').ExtensionRecord[]} extensions + * @returns {string} + */ +export function formatExtensionList(extensions) { + if (extensions.length === 0) return chalk.dim(' (none)'); + const blocks = []; + for (const ext of extensions) { + const lines = []; + const tag = ext.builtin + ? chalk.yellow('[builtin]') + : chalk.magenta('[custom]'); + lines.push(`${chalk.cyan.bold(ext.name)} ${chalk.dim(ext.version)} ${tag}`); + if (ext.description) { + lines.push(` ${chalk.dim(ext.description)}`); + } + if (ext.views.length > 0) { + lines.push(` ${chalk.dim('views:')} ${ext.views.map(v => v.name).join(', ')}`); + } + if (ext.lenses.length > 0) { + lines.push(` ${chalk.dim('lenses:')} ${ext.lenses.join(', ')}`); + } + blocks.push(lines.join('\n')); + } + return blocks.join('\n\n'); +} + /** * Format an import result for terminal display. * @param {import('../import.js').ImportResult} result diff --git a/src/extension.js b/src/extension.js index df394ab4..886f7f32 100644 --- a/src/extension.js +++ b/src/extension.js @@ -51,6 +51,9 @@ const registry = new Map(); /** @type {Map} Snapshot of built-in extensions */ let builtInDefs = new Map(); +/** Whether registerBuiltinExtensions() has already been called. */ +let builtInsLoaded = false; + // ── Core API ───────────────────────────────────────────────────── /** @@ -96,8 +99,24 @@ export async function loadExtension(manifestPath) { * @param {ExtensionRecord} record * @param {{ skipViews?: boolean }} [opts] * @throws {Error} If a referenced lens is not registered + * @throws {Error} If incoming prefixes collide with another registered extension */ export function registerExtension(record, opts = {}) { + // Check for prefix collisions with other registered extensions + const incoming = record.domain?.prefixes ?? []; + if (incoming.length > 0) { + for (const [existingName, existing] of registry) { + if (existingName === record.name) continue; // allow idempotent re-register + const existingPrefixes = new Set(existing.domain?.prefixes ?? []); + const overlap = incoming.filter(p => existingPrefixes.has(p)); + if (overlap.length > 0) { + throw new Error( + `Extension "${record.name}" declares prefix(es) [${overlap.join(', ')}] already owned by "${existingName}"` + ); + } + } + } + // Verify all referenced lenses exist for (const lensName of record.lenses) { if (!getLens(lensName)) { @@ -132,6 +151,27 @@ export function getExtension(name) { return registry.get(name); } +/** + * Remove a registered extension by name. + * Throws if the extension is not registered or if it is a built-in. + * Note: does NOT undeclare views — views.js has no per-extension tracking. + * + * @param {string} name + * @returns {ExtensionRecord} The removed record + * @throws {Error} If not registered or if built-in + */ +export function removeExtension(name) { + const record = registry.get(name); + if (!record) { + throw new Error(`Extension "${name}" is not registered`); + } + if (record.builtin) { + throw new Error(`Cannot remove built-in extension "${name}"`); + } + registry.delete(name); + return record; +} + /** * Validate a manifest file without registering it. * Returns validation result + parsed record (or null on failure). @@ -174,6 +214,7 @@ export function captureBuiltIns() { */ export function _resetBuiltInsForTest() { builtInDefs.clear(); + builtInsLoaded = false; } /** @@ -184,12 +225,14 @@ export function _resetBuiltInsForTest() { * @returns {Promise} */ export async function registerBuiltinExtensions() { + if (builtInsLoaded) return; for (const url of BUILTIN_MANIFESTS) { const manifestPath = fileURLToPath(url); const record = await loadExtension(manifestPath); registerExtension({ ...record, builtin: true }); } captureBuiltIns(); + builtInsLoaded = true; } // ── Internal helpers ───────────────────────────────────────────── diff --git a/src/index.js b/src/index.js index 27fa07a8..44e63c11 100644 --- a/src/index.js +++ b/src/index.js @@ -45,6 +45,6 @@ export { export { computeDiff, diffSnapshots } from './diff.js'; export { createContext, DEFAULT_CONTEXT } from './context-envelope.js'; export { - loadExtension, registerExtension, listExtensions, getExtension, + loadExtension, registerExtension, removeExtension, listExtensions, getExtension, validateExtension, resetExtensions, registerBuiltinExtensions, } from './extension.js'; diff --git a/test/contracts.test.js b/test/contracts.test.js index 3e381080..0e28bbe1 100644 --- a/test/contracts.test.js +++ b/test/contracts.test.js @@ -212,6 +212,41 @@ const VALID_SAMPLES = { }, }, }, + 'extension-list.schema.json': { + schemaVersion: 1, + command: 'extension-list', + extensions: [ + { + name: 'roadmap', + version: '1.0.0', + description: 'Project roadmap domain', + builtin: true, + views: [{ name: 'roadmap', prefixes: ['phase', 'task'] }], + lenses: ['incomplete', 'frontier'], + }, + ], + }, + 'extension-validate.schema.json': { + schemaVersion: 1, + command: 'extension-validate', + valid: true, + errors: [], + record: { name: 'test-ext', version: '1.0.0' }, + }, + 'extension-add.schema.json': { + schemaVersion: 1, + command: 'extension-add', + name: 'test-ext', + version: '1.0.0', + views: ['widgets'], + lenses: ['incomplete'], + }, + 'extension-remove.schema.json': { + schemaVersion: 1, + command: 'extension-remove', + name: 'test-ext', + version: '1.0.0', + }, }; describe('CLI JSON Schema contracts', () => { diff --git a/test/extension.integration.test.js b/test/extension.integration.test.js index 0f1c0475..76fce02b 100644 --- a/test/extension.integration.test.js +++ b/test/extension.integration.test.js @@ -66,10 +66,12 @@ describe('roadmap manifest content', () => { expect(record.domain.prefixes).toContain('feature'); }); - it('declares roadmap and backlog views', () => { + it('declares roadmap, backlog, milestone, and progress views', () => { const names = record.views.map(v => v.name); expect(names).toContain('roadmap'); expect(names).toContain('backlog'); + expect(names).toContain('milestone'); + expect(names).toContain('progress'); }); it('references only built-in lenses', () => { @@ -95,9 +97,12 @@ describe('architecture manifest content', () => { expect(record.domain.prefixes).toContain('pkg'); }); - it('declares architecture view', () => { + it('declares architecture, traceability, coverage, and onboarding views', () => { const names = record.views.map(v => v.name); expect(names).toContain('architecture'); + expect(names).toContain('traceability'); + expect(names).toContain('coverage'); + expect(names).toContain('onboarding'); }); it('references only built-in lenses', () => { diff --git a/test/extension.test.js b/test/extension.test.js index 1b28560e..5b442bbf 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -11,11 +11,13 @@ import { fileURLToPath } from 'node:url'; import { loadExtension, registerExtension, + removeExtension, listExtensions, getExtension, validateExtension, resetExtensions, captureBuiltIns, + registerBuiltinExtensions, _resetBuiltInsForTest, } from '../src/extension.js'; import { listLenses } from '../src/lens.js'; @@ -184,6 +186,40 @@ describe('registerExtension', () => { const matches = listExtensions().filter(e => e.name === 'test-ext'); expect(matches).toHaveLength(1); }); + + it('throws on prefix collision with another extension', async () => { + const yamlA = `name: ext-a\nversion: 1.0.0\ndomain:\n prefixes: [widget]\n`; + const yamlB = `name: ext-b\nversion: 1.0.0\ndomain:\n prefixes: [widget]\n`; + const pathA = join(tempDir, 'ext-a.yaml'); + const pathB = join(tempDir, 'ext-b.yaml'); + await writeFile(pathA, yamlA); + await writeFile(pathB, yamlB); + const recA = await loadExtension(pathA); + const recB = await loadExtension(pathB); + registerExtension(recA); + expect(() => registerExtension(recB)).toThrow(/prefix.*widget.*already owned by.*ext-a/i); + }); + + it('allows disjoint prefixes without collision', async () => { + const yamlA = `name: ext-a\nversion: 1.0.0\ndomain:\n prefixes: [alpha]\n`; + const yamlB = `name: ext-b\nversion: 1.0.0\ndomain:\n prefixes: [beta]\n`; + const pathA = join(tempDir, 'ext-a.yaml'); + const pathB = join(tempDir, 'ext-b.yaml'); + await writeFile(pathA, yamlA); + await writeFile(pathB, yamlB); + const recA = await loadExtension(pathA); + const recB = await loadExtension(pathB); + registerExtension(recA); + expect(() => registerExtension(recB)).not.toThrow(); + }); + + it('built-in roadmap + architecture have no prefix collisions', async () => { + await registerBuiltinExtensions(); + const exts = listExtensions(); + expect(exts.length).toBeGreaterThanOrEqual(2); + const allPrefixes = exts.flatMap(e => e.domain?.prefixes ?? []); + expect(new Set(allPrefixes).size).toBe(allPrefixes.length); + }); }); // ── validateExtension ────────────────────────────────────────────── @@ -269,3 +305,52 @@ describe('resetExtensions / captureBuiltIns', () => { expect(getExtension('test-ext')).toBeDefined(); // survived reset }); }); + +// ── removeExtension ───────────────────────────────────────────────── + +describe('removeExtension', () => { + it('removes a custom extension but views persist', async () => { + const path = join(tempDir, 'extension.yaml'); + await writeFile(path, VALID_YAML); + const record = await loadExtension(path); + registerExtension(record); + expect(getExtension('test-ext')).toBeDefined(); + const removed = removeExtension('test-ext'); + expect(removed.name).toBe('test-ext'); + expect(getExtension('test-ext')).toBeUndefined(); + // Views declared by a removed extension are NOT automatically unregistered + // (views.js has no per-extension tracking — see removeExtension JSDoc) + }); + + it('throws when removing a built-in extension', async () => { + await registerBuiltinExtensions(); + const builtinName = listExtensions().find(e => e.builtin)?.name; + expect(builtinName).toBeDefined(); + expect(() => removeExtension(builtinName)).toThrow(/cannot remove built-in/i); + }); + + it('throws when removing a non-existent extension', () => { + expect(() => removeExtension('no-such-ext')).toThrow(/not registered/i); + }); +}); + +// ── registerBuiltinExtensions memoization ────────────────────────── + +describe('registerBuiltinExtensions memoization', () => { + it('calling twice does not create duplicate registrations', async () => { + await registerBuiltinExtensions(); + const countAfterFirst = listExtensions().length; + await registerBuiltinExtensions(); + expect(listExtensions().length).toBe(countAfterFirst); + }); + + it('_resetBuiltInsForTest allows re-loading', async () => { + await registerBuiltinExtensions(); + expect(listExtensions().length).toBeGreaterThan(0); + _resetBuiltInsForTest(); + resetExtensions(); + expect(listExtensions()).toHaveLength(0); + await registerBuiltinExtensions(); + expect(listExtensions().length).toBeGreaterThan(0); + }); +});