From 533aeb0584ea7aaae2a5df3a5136e4a353bea8bf Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 17:04:41 -0800 Subject: [PATCH 01/15] docs: add ADR-0004 content attachments belong in git-warp (#252) Establishes that CAS-backed content-on-node is a git-warp substrate responsibility, aligning with Paper I's Atom(p) vertex attachment formalism. git-mind provides CLI/UX layer on top. --- CHANGELOG.md | 6 ++ docs/adr/ADR-0004.md | 135 +++++++++++++++++++++++++++++++++++++++++++ docs/adr/README.md | 15 +++++ 3 files changed, 156 insertions(+) create mode 100644 docs/adr/ADR-0004.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bbd1c3..e6ac623a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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) + ## [3.2.0] - 2026-02-17 ### Changed diff --git a/docs/adr/ADR-0004.md b/docs/adr/ADR-0004.md new file mode 100644 index 00000000..d8bbab39 --- /dev/null +++ b/docs/adr/ADR-0004.md @@ -0,0 +1,135 @@ +# ADR-0004: Content Attachments Belong in git-warp + +- **Status:** Proposed +- **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..85b0e14a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -88,6 +88,21 @@ Recommended sections: --- +## ADR-0004 — Content Attachments Belong in git-warp +**Status:** Proposed +**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. From ae72cd2288e34c09b9511a8e4d9d04d5423bd883 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:44:15 -0800 Subject: [PATCH 02/15] feat(cli): add chalk formatting to extension list output (#265) Replace plain console.log with formatExtensionList() using chalk for consistent terminal styling across all CLI commands. --- src/cli/commands.js | 14 ++------------ src/cli/format.js | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/cli/commands.js b/src/cli/commands.js index 418b61e9..5c8c4726 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -25,7 +25,7 @@ import { getPendingSuggestions, acceptSuggestion, rejectSuggestion, skipSuggesti 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 { 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. @@ -824,17 +824,7 @@ export function extensionList(_cwd, opts = {}) { 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)); } /** diff --git a/src/cli/format.js b/src/cli/format.js index 4ce0a98a..d7c2ade8 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -498,6 +498,31 @@ 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) { + const lines = []; + for (const ext of extensions) { + 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(', ')}`); + } + } + return lines.join('\n'); +} + /** * Format an import result for terminal display. * @param {import('../import.js').ImportResult} result From 45b29f237693e4a14e73dcd375f49518cec7f007 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:45:02 -0800 Subject: [PATCH 03/15] perf(extension): memoize registerBuiltinExtensions (#266) Add builtInsLoaded flag to avoid redundant YAML file reads on repeated invocations. Reset flag in _resetBuiltInsForTest() for test isolation. --- src/extension.js | 6 ++++++ test/extension.test.js | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/extension.js b/src/extension.js index df394ab4..06b8e995 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 ───────────────────────────────────────────────────── /** @@ -174,6 +177,7 @@ export function captureBuiltIns() { */ export function _resetBuiltInsForTest() { builtInDefs.clear(); + builtInsLoaded = false; } /** @@ -184,12 +188,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/test/extension.test.js b/test/extension.test.js index 1b28560e..b608843d 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -16,6 +16,7 @@ import { validateExtension, resetExtensions, captureBuiltIns, + registerBuiltinExtensions, _resetBuiltInsForTest, } from '../src/extension.js'; import { listLenses } from '../src/lens.js'; @@ -269,3 +270,24 @@ describe('resetExtensions / captureBuiltIns', () => { expect(getExtension('test-ext')).toBeDefined(); // survived reset }); }); + +// ── 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); + }); +}); From 79fac6c3acc33609e65543ef2dfd2a94546169af Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:45:41 -0800 Subject: [PATCH 04/15] feat(extension): detect and reject prefix collisions (#264) registerExtension() now checks incoming prefixes against all other registered extensions. Throws a descriptive error on overlap while still allowing idempotent re-registration of the same extension name. --- src/extension.js | 14 ++++++++++++++ test/extension.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/extension.js b/src/extension.js index 06b8e995..be0e4f52 100644 --- a/src/extension.js +++ b/src/extension.js @@ -101,6 +101,20 @@ export async function loadExtension(manifestPath) { * @throws {Error} If a referenced lens is not registered */ 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 overlap = incoming.filter(p => existing.domain.prefixes.includes(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)) { diff --git a/test/extension.test.js b/test/extension.test.js index b608843d..e806747a 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -185,6 +185,39 @@ 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); + // If we got here without throw, no collisions + }); }); // ── validateExtension ────────────────────────────────────────────── From 89bb52dff1e40730fa1798f32739d989cb5958c7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:46:20 -0800 Subject: [PATCH 05/15] feat(extensions): declare imperative views in built-in manifests (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add milestone, progress, traceability, coverage, and onboarding views to their respective extension manifests so extensionList shows the full picture. Purely declarative — no runtime behavior change since builtin=true skips declareView(). --- extensions/architecture/extension.yaml | 9 +++++++++ extensions/roadmap/extension.yaml | 6 ++++++ test/extension.integration.test.js | 9 +++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) 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..e7eb39d7 100644 --- a/extensions/roadmap/extension.yaml +++ b/extensions/roadmap/extension.yaml @@ -16,5 +16,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/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', () => { From 1e0726d7cf5342a94b41058430def7b31834d870 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:47:53 -0800 Subject: [PATCH 06/15] feat(cli): add extension remove subcommand (#263) Add removeExtension() to the extension runtime (throws on built-in or non-existent), export from public API, and wire as `git mind extension remove ` in the CLI. --- bin/git-mind.js | 9 +++++++-- src/cli/commands.js | 30 +++++++++++++++++++++++++++++- src/extension.js | 21 +++++++++++++++++++++ src/index.js | 2 +- test/extension.test.js | 25 +++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/bin/git-mind.js b/bin/git-mind.js index 4c78c096..cceb65ba 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'; @@ -389,9 +389,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/src/cli/commands.js b/src/cli/commands.js index 5c8c4726..859323b2 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -24,7 +24,7 @@ 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 { 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'; /** @@ -888,3 +888,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/extension.js b/src/extension.js index be0e4f52..2bca8ab4 100644 --- a/src/extension.js +++ b/src/extension.js @@ -149,6 +149,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). 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/extension.test.js b/test/extension.test.js index e806747a..0bf9cc3e 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'; import { loadExtension, registerExtension, + removeExtension, listExtensions, getExtension, validateExtension, @@ -304,6 +305,30 @@ describe('resetExtensions / captureBuiltIns', () => { }); }); +// ── removeExtension ───────────────────────────────────────────────── + +describe('removeExtension', () => { + it('removes a custom extension', 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(); + }); + + it('throws when removing a built-in extension', async () => { + await registerBuiltinExtensions(); + expect(() => removeExtension('roadmap')).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', () => { From 178ca449228332d76c6963d32349126b415dad14 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 01:49:13 -0800 Subject: [PATCH 07/15] docs(contracts): add JSON schemas for extension CLI output (#262) Add 4 new CLI output schemas: extension-list, extension-validate, extension-add, and extension-remove. Valid samples added to the contract test harness. --- docs/contracts/cli/extension-add.schema.json | 23 ++++++++++ docs/contracts/cli/extension-list.schema.json | 45 +++++++++++++++++++ .../cli/extension-remove.schema.json | 15 +++++++ .../cli/extension-validate.schema.json | 25 +++++++++++ test/contracts.test.js | 35 +++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 docs/contracts/cli/extension-add.schema.json create mode 100644 docs/contracts/cli/extension-list.schema.json create mode 100644 docs/contracts/cli/extension-remove.schema.json create mode 100644 docs/contracts/cli/extension-validate.schema.json 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..7c089a36 --- /dev/null +++ b/docs/contracts/cli/extension-list.schema.json @@ -0,0 +1,45 @@ +{ + "$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"], + "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/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', () => { From a13587bd67b7a23a71325a246e940b14164b0f66 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 02:01:30 -0800 Subject: [PATCH 08/15] docs: update CHANGELOG and ROADMAP for post-M12 polish (#261, #269) Add M12 polish entries to CHANGELOG (6 implemented, 2 deferred). Document deferred #261 (ephemeral registration) and #269 (--extension flag) in ROADMAP backlog with rationale and recommended H2 slot. --- CHANGELOG.md | 11 +++++++++++ ROADMAP.md | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ac623a..5ff10cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 + +- **`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 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) From 7a4238fead9ac0263d0b4b69963cbd5bd73347b1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 03:44:37 -0800 Subject: [PATCH 09/15] chore(deps): upgrade @git-stunts/git-warp to v11.5.0 (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 537 tests passing — no breaking changes. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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", From ddf879278eab1bc056a3d776ec95c542f0fbe805 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 03:56:01 -0800 Subject: [PATCH 10/15] docs(changelog): add git-warp v11.5.0 upgrade entry (#252) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff10cdd..5ce8d274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) From 4eef22b0153a8cb810767391930dee7e8271b9ee Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 04:33:26 -0800 Subject: [PATCH 11/15] fix: strip extensionList JSON output to match schema, add missing help text (#270) - C1: extensionList --json now cherry-picks only schema-declared fields (name, version, description, builtin, views, lenses), matching the extension-list.schema.json contract with additionalProperties: false - M1: add `remove` subcommand to extension help text in printUsage() - M2: add @throws JSDoc for prefix collision error in registerExtension() - L2: use Set for O(1) prefix collision lookup in registerExtension() --- bin/git-mind.js | 2 ++ src/cli/commands.js | 11 ++++++++++- src/extension.js | 4 +++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bin/git-mind.js b/bin/git-mind.js index cceb65ba..84a6f282 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.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`); diff --git a/src/cli/commands.js b/src/cli/commands.js index 859323b2..2cff8c00 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -817,7 +817,16 @@ 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) { diff --git a/src/extension.js b/src/extension.js index 2bca8ab4..bf608870 100644 --- a/src/extension.js +++ b/src/extension.js @@ -99,6 +99,7 @@ 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 @@ -106,7 +107,8 @@ export function registerExtension(record, opts = {}) { if (incoming.length > 0) { for (const [existingName, existing] of registry) { if (existingName === record.name) continue; // allow idempotent re-register - const overlap = incoming.filter(p => existing.domain.prefixes.includes(p)); + 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}"` From 8f7d92109f231b28bd270dec5075df2068eecb6a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 04:42:03 -0800 Subject: [PATCH 12/15] fix: address CodeRabbit review feedback on PR #270 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0004: status Proposed → Accepted (ships with implementation) - ADR README: fix placeholder headings (ADR-00XX/00XY → ADR-0002/0003) - ADR README: sync ADR-0004 status to Accepted - extension-list schema: add additionalProperties: false to view items - roadmap extension.yaml: remove stale comment contradicting views section - formatExtensionList: add blank-line separators + empty-state guard - registerExtension: defensive optional chaining on existing.domain?.prefixes - Tests: explicit prefix uniqueness assertion, view persistence docs, dynamic built-in name lookup in removal test --- docs/adr/ADR-0004.md | 2 +- docs/adr/README.md | 14 +++++++------- docs/contracts/cli/extension-list.schema.json | 1 + extensions/roadmap/extension.yaml | 2 -- src/cli/format.js | 7 +++++-- src/extension.js | 2 +- test/extension.test.js | 13 +++++++++---- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/adr/ADR-0004.md b/docs/adr/ADR-0004.md index d8bbab39..6dc75876 100644 --- a/docs/adr/ADR-0004.md +++ b/docs/adr/ADR-0004.md @@ -1,6 +1,6 @@ # ADR-0004: Content Attachments Belong in git-warp -- **Status:** Proposed +- **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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 85b0e14a..623470ec 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 — 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 — 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. @@ -89,7 +89,7 @@ Recommended sections: --- ## ADR-0004 — Content Attachments Belong in git-warp -**Status:** Proposed +**Status:** Accepted **Date:** 2026-02-20 ### What it establishes diff --git a/docs/contracts/cli/extension-list.schema.json b/docs/contracts/cli/extension-list.schema.json index 7c089a36..b96721b9 100644 --- a/docs/contracts/cli/extension-list.schema.json +++ b/docs/contracts/cli/extension-list.schema.json @@ -25,6 +25,7 @@ "items": { "type": "object", "required": ["name"], + "additionalProperties": false, "properties": { "name": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, diff --git a/extensions/roadmap/extension.yaml b/extensions/roadmap/extension.yaml index e7eb39d7..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] diff --git a/src/cli/format.js b/src/cli/format.js index d7c2ade8..b9025b78 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -504,8 +504,10 @@ export function formatProgressMeta(meta) { * @returns {string} */ export function formatExtensionList(extensions) { - const lines = []; + 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]'); @@ -519,8 +521,9 @@ export function formatExtensionList(extensions) { if (ext.lenses.length > 0) { lines.push(` ${chalk.dim('lenses:')} ${ext.lenses.join(', ')}`); } + blocks.push(lines.join('\n')); } - return lines.join('\n'); + return blocks.join('\n\n'); } /** diff --git a/src/extension.js b/src/extension.js index bf608870..886f7f32 100644 --- a/src/extension.js +++ b/src/extension.js @@ -107,7 +107,7 @@ export function registerExtension(record, opts = {}) { 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 existingPrefixes = new Set(existing.domain?.prefixes ?? []); const overlap = incoming.filter(p => existingPrefixes.has(p)); if (overlap.length > 0) { throw new Error( diff --git a/test/extension.test.js b/test/extension.test.js index 0bf9cc3e..761569c3 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -214,10 +214,11 @@ describe('registerExtension', () => { }); it('built-in roadmap + architecture have no prefix collisions', async () => { - await registerBuiltinExtensions(); + await expect(registerBuiltinExtensions()).resolves.not.toThrow(); const exts = listExtensions(); expect(exts.length).toBeGreaterThanOrEqual(2); - // If we got here without throw, no collisions + const allPrefixes = exts.flatMap(e => e.domain?.prefixes ?? []); + expect(new Set(allPrefixes).size).toBe(allPrefixes.length); }); }); @@ -308,7 +309,7 @@ describe('resetExtensions / captureBuiltIns', () => { // ── removeExtension ───────────────────────────────────────────────── describe('removeExtension', () => { - it('removes a custom extension', async () => { + 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); @@ -317,11 +318,15 @@ describe('removeExtension', () => { 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(); - expect(() => removeExtension('roadmap')).toThrow(/cannot remove built-in/i); + 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', () => { From 1515ca65fd1fa78e66199b7c8205e97dcb03e91c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 04:44:00 -0800 Subject: [PATCH 13/15] chore: fix ADR placeholder IDs in PR template (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update ADR-00XX → ADR-0002 and ADR-00XY → ADR-0003 to match the actual ADR index headings fixed in the same PR. --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 6dfc19bbc968528674006b711c7fdf7f883c7344 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 04:49:06 -0800 Subject: [PATCH 14/15] fix: remove unnecessary await on synchronous extensionList call (#270) --- bin/git-mind.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/git-mind.js b/bin/git-mind.js index 84a6f282..59cf9edf 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -379,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('--')); From a41637f578550bd88ce2d7ca9b38c923d50d2998 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 21 Feb 2026 04:58:15 -0800 Subject: [PATCH 15/15] fix: correct test assertion and add ADR index links (#270) - Replace semantically incorrect .resolves.not.toThrow() with plain await (registerBuiltinExtensions returns void, toThrow on undefined is meaningless) - Add relative markdown links to ADR files in the index README --- docs/adr/README.md | 6 +++--- test/extension.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 623470ec..2f4fc8bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ Use ADRs for decisions that are hard to reverse, cross-cut multiple subsystems, ## ADR Index -## ADR-0002 — Worktree Independence and Materialization Architecture +## [ADR-0002](./ADR-0002.md) — Worktree Independence and Materialization Architecture **Status:** Accepted **Date:** 2026-02-15 @@ -24,7 +24,7 @@ Defines the core separation model: **worktree-aware, never worktree-bound**. --- -## ADR-0003 — Graph-Native Content, Deterministic Materialization, and Workspace Bridge +## [ADR-0003](./ADR-0003.md) — Graph-Native Content, Deterministic Materialization, and Workspace Bridge **Status:** Accepted **Date:** 2026-02-15 @@ -88,7 +88,7 @@ Recommended sections: --- -## ADR-0004 — Content Attachments Belong in git-warp +## [ADR-0004](./ADR-0004.md) — Content Attachments Belong in git-warp **Status:** Accepted **Date:** 2026-02-20 diff --git a/test/extension.test.js b/test/extension.test.js index 761569c3..5b442bbf 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -214,7 +214,7 @@ describe('registerExtension', () => { }); it('built-in roadmap + architecture have no prefix collisions', async () => { - await expect(registerBuiltinExtensions()).resolves.not.toThrow(); + await registerBuiltinExtensions(); const exts = listExtensions(); expect(exts.length).toBeGreaterThanOrEqual(2); const allPrefixes = exts.flatMap(e => e.domain?.prefixes ?? []);