From ff4ab14fd22abfdeffb3208f6a7269daeb3832c2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 11:57:53 -0700 Subject: [PATCH 01/19] feat(config): model deja-vu as a managed companion --- src/lib/adapters/companion-registry.mjs | 61 ++++++++++++++++++++ src/lib/adapters/config.mjs | 49 +++++++++++++++- src/lib/adapters/index.mjs | 1 + src/lib/config.mjs | 17 ++++++ tests/kit/companion-registry.test.mjs | 33 +++++++++++ tests/kit/integration-config.test.mjs | 77 ++++++++++++++++++++++++- tests/kit/settings-config.test.mjs | 29 ++++++++++ tests/kit/setup-host-flags.test.mjs | 7 ++- 8 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 src/lib/adapters/companion-registry.mjs create mode 100644 tests/kit/companion-registry.test.mjs diff --git a/src/lib/adapters/companion-registry.mjs b/src/lib/adapters/companion-registry.mjs new file mode 100644 index 0000000..c274b06 --- /dev/null +++ b/src/lib/adapters/companion-registry.mjs @@ -0,0 +1,61 @@ +import { + assertId, + assertRecord, + assertStringArray, + immutable, + registryFrom, +} from './schema.mjs'; +import { HOST_REGISTRY } from './registries.mjs'; + +function validateManagedCompanion(value, { hosts = HOST_REGISTRY } = {}) { + assertRecord(value, 'managed companion'); + assertId(value.id, 'managed companion.id'); + if (typeof value.configKey !== 'string' || !/^[a-z][A-Za-z0-9]*$/.test(value.configKey)) { + throw new TypeError('managed companion.configKey must be camelCase'); + } + if (typeof value.label !== 'string' || !value.label) { + throw new TypeError('managed companion.label is required'); + } + if (typeof value.enabledByDefault !== 'boolean') { + throw new TypeError('managed companion.enabledByDefault must be boolean'); + } + assertStringArray(value.modes, 'managed companion.modes', { allowEmpty: false }); + assertStringArray(value.hosts, 'managed companion.hosts', { allowEmpty: false }); + const knownHosts = new Set(hosts.map(({ id }) => id)); + for (const host of value.hosts) { + if (!knownHosts.has(host)) { + throw new TypeError(`managed companion.hosts contains unknown host '${host}'`); + } + } + assertRecord(value.install, 'managed companion.install'); + for (const field of ['bin', 'npmPackage', 'minimumVersion']) { + if (typeof value.install[field] !== 'string' || !value.install[field]) { + throw new TypeError(`managed companion.install.${field} is required`); + } + } + return immutable(structuredClone(value)); +} + +const COMPANION_MAP = registryFrom([ + { + id: 'deja-vu', + configKey: 'dejaVu', + label: 'deja-vu', + enabledByDefault: false, + modes: ['mcp', 'auto'], + hosts: ['claude', 'codex', 'opencode'], + install: { + bin: 'deja', + npmPackage: '@vshulcz/deja-vu', + minimumVersion: '0.19.0', + }, + }, +], validateManagedCompanion, 'managed companion'); + +export const MANAGED_COMPANION_REGISTRY = immutable(Object.values(COMPANION_MAP)); + +export const managedCompanionIds = () => MANAGED_COMPANION_REGISTRY.map(({ id }) => id); + +export function managedCompanionFor(id) { + return COMPANION_MAP[id] ?? null; +} diff --git a/src/lib/adapters/config.mjs b/src/lib/adapters/config.mjs index 8d975d8..0dc6c69 100644 --- a/src/lib/adapters/config.mjs +++ b/src/lib/adapters/config.mjs @@ -1,8 +1,18 @@ import { isDeepStrictEqual } from 'node:util'; import { immutable } from './schema.mjs'; +import { managedCompanionFor } from './companion-registry.mjs'; import { PROVIDER_REGISTRY } from './registries.mjs'; -export const CURRENT_INTEGRATIONS_VERSION = 2; +export const CURRENT_INTEGRATIONS_VERSION = 3; + +const DEJA_VU_COMPANION = managedCompanionFor('deja-vu'); + +export const DEFAULT_DEJA_VU_INTENT = immutable({ + enabled: DEJA_VU_COMPANION.enabledByDefault, + mode: 'mcp', + hosts: [], + indexOnSetup: true, +}); // A host's native provider = the registry provider with host-login // credentials whose projections include that host (anthropic -> claude, @@ -33,6 +43,35 @@ const NATIVE_PROVIDER_BY_HOST = buildNativeProviderByHost(PROVIDER_REGISTRY); const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const own = (value, key) => plain(value) && Object.hasOwn(value, key); +export function validateDejaVuIntent(value) { + if (!plain(value)) throw new TypeError('integrations.tools.dejaVu must be an object'); + if (typeof value.enabled !== 'boolean') { + throw new TypeError('integrations.tools.dejaVu.enabled must be boolean'); + } + if (!DEJA_VU_COMPANION.modes.includes(value.mode)) { + throw new TypeError( + `integrations.tools.dejaVu.mode must be one of: ${DEJA_VU_COMPANION.modes.join(', ')}`, + ); + } + if (!Array.isArray(value.hosts) + || value.hosts.some((host) => typeof host !== 'string' || !host)) { + throw new TypeError('integrations.tools.dejaVu.hosts must be an array of non-empty strings'); + } + if (new Set(value.hosts).size !== value.hosts.length) { + throw new TypeError('integrations.tools.dejaVu.hosts contains duplicates'); + } + const knownHosts = new Set(DEJA_VU_COMPANION.hosts); + for (const host of value.hosts) { + if (!knownHosts.has(host)) { + throw new TypeError(`integrations.tools.dejaVu.hosts contains unknown host '${host}'`); + } + } + if (typeof value.indexOnSetup !== 'boolean') { + throw new TypeError('integrations.tools.dejaVu.indexOnSetup must be boolean'); + } + return value; +} + function mergeBindings(current = [], legacy = []) { const merged = structuredClone(current); for (const binding of legacy) { @@ -76,6 +115,7 @@ export function migrateIntegrationConfig(config = {}, _options = {}) { return immutable(out); } if (Object.hasOwn(existing, 'hosts') && !plain(existing.hosts)) return immutable(out); + if (Object.hasOwn(existing, 'tools') && !plain(existing.tools)) return immutable(out); if (Object.hasOwn(existing, 'ownership') && !plain(existing.ownership)) return immutable(out); const providers = plain(out.providers) ? out.providers : {}; @@ -113,6 +153,12 @@ export function migrateIntegrationConfig(config = {}, _options = {}) { }) .filter((binding) => binding !== null); const ownership = structuredClone(existing.ownership ?? {}); + const tools = structuredClone(existing.tools ?? {}); + tools.dejaVu = plain(tools.dejaVu) + ? { ...structuredClone(DEFAULT_DEJA_VU_INTENT), ...tools.dejaVu } + : Object.hasOwn(tools, 'dejaVu') + ? tools.dejaVu + : structuredClone(DEFAULT_DEJA_VU_INTENT); const reverseMarker = 'rufloCodexMcp'; const hasLegacyCodex = (own(providers, 'codexMcp') && providers.codexMcp != null) || (own(providers, reverseMarker) && providers[reverseMarker] != null); @@ -144,6 +190,7 @@ export function migrateIntegrationConfig(config = {}, _options = {}) { version: CURRENT_INTEGRATIONS_VERSION, hosts, bindings: [...priorBindings, ...inferred], + tools, ...(Object.keys(ownership).length ? { ownership } : {}), }; delete out.integrations.schemaVersion; diff --git a/src/lib/adapters/index.mjs b/src/lib/adapters/index.mjs index 8794885..365ed2b 100644 --- a/src/lib/adapters/index.mjs +++ b/src/lib/adapters/index.mjs @@ -1,5 +1,6 @@ export * from './schema.mjs'; export * from './registries.mjs'; +export * from './companion-registry.mjs'; export * from './bindings.mjs'; export * from './facts.mjs'; export * from './migration.mjs'; diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 4bd4a9e..12279a0 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -6,7 +6,9 @@ import path from 'node:path'; import { kitConfigPath, legacyKitConfigPath } from './paths.mjs'; import { CURRENT_INTEGRATIONS_VERSION, + DEFAULT_DEJA_VU_INTENT, migrateIntegrationConfig, + validateDejaVuIntent, } from './adapters/config.mjs'; import { defaultHostMap } from './adapters/registries.mjs'; import { @@ -28,6 +30,9 @@ const DEFAULTS = { version: CURRENT_INTEGRATIONS_VERSION, hosts: defaultHostMap(), bindings: [], + tools: { + dejaVu: structuredClone(DEFAULT_DEJA_VU_INTENT), + }, }, routing: { version: ROUTING_SCHEMA_VERSION, @@ -96,6 +101,10 @@ function assertLoadableEnvelopes(config) { if (!Array.isArray(config.integrations.bindings)) { throw new TypeError('integrations.bindings must be an array'); } + if (!plain(config.integrations.tools)) { + throw new TypeError('integrations.tools must be an object'); + } + validateDejaVuIntent(config.integrations.tools.dejaVu); if (config.integrations.ownership !== undefined && !plain(config.integrations.ownership)) { throw new TypeError('integrations.ownership must be an object'); } @@ -133,6 +142,14 @@ function withDefaults(config) { ...structuredClone(DEFAULTS.integrations), ...config.integrations, hosts: { ...DEFAULTS.integrations.hosts, ...config.integrations.hosts }, + tools: { + ...structuredClone(DEFAULTS.integrations.tools), + ...config.integrations.tools, + dejaVu: { + ...structuredClone(DEFAULTS.integrations.tools.dejaVu), + ...config.integrations.tools.dejaVu, + }, + }, } : config.integrations; const routing = config.routing?.version === ROUTING_SCHEMA_VERSION diff --git a/tests/kit/companion-registry.test.mjs b/tests/kit/companion-registry.test.mjs new file mode 100644 index 0000000..3fbdffb --- /dev/null +++ b/tests/kit/companion-registry.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + HOST_REGISTRY, + MANAGED_COMPANION_REGISTRY, + managedCompanionFor, + managedCompanionIds, +} from '../../src/lib/adapters/index.mjs'; + +test('deja-vu is a static managed companion, not a host', () => { + assert.deepEqual(managedCompanionIds(), ['deja-vu']); + assert.equal(HOST_REGISTRY.some(({ id }) => id === 'deja-vu'), false); + + const companion = managedCompanionFor('deja-vu'); + assert.equal(companion.configKey, 'dejaVu'); + assert.equal(companion.enabledByDefault, false); + assert.deepEqual(companion.modes, ['mcp', 'auto']); + assert.deepEqual(companion.hosts, ['claude', 'codex', 'opencode']); + assert.deepEqual(companion.install, { + bin: 'deja', + npmPackage: '@vshulcz/deja-vu', + minimumVersion: '0.19.0', + }); +}); + +test('managed companion registry is immutable and lookup is nullable', () => { + assert.equal(Object.isFrozen(MANAGED_COMPANION_REGISTRY), true); + assert.equal(Object.isFrozen(MANAGED_COMPANION_REGISTRY[0]), true); + assert.equal(managedCompanionFor('unknown'), null); + assert.throws(() => { + MANAGED_COMPANION_REGISTRY[0].hosts.push('unknown'); + }, TypeError); +}); diff --git a/tests/kit/integration-config.test.mjs b/tests/kit/integration-config.test.mjs index 62558a1..09df5d2 100644 --- a/tests/kit/integration-config.test.mjs +++ b/tests/kit/integration-config.test.mjs @@ -2,7 +2,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { CURRENT_INTEGRATIONS_VERSION, + DEFAULT_DEJA_VU_INTENT, migrateIntegrationConfig, + validateDejaVuIntent, validateEndpoint, } from '../../src/lib/adapters/config.mjs'; import { migrateConfig } from '../../src/lib/adapters/migration.mjs'; @@ -28,8 +30,8 @@ test('integration migration is versioned, deterministic, and idempotent', () => const input = structuredClone(legacyDual); const first = migrateIntegrationConfig(input); const second = migrateIntegrationConfig(structuredClone(first)); - assert.equal(CURRENT_INTEGRATIONS_VERSION, 2, - 'v2 distinguishes completed GA cutover from additive v1 snapshots'); + assert.equal(CURRENT_INTEGRATIONS_VERSION, 3, + 'v3 adds managed companion intent without inferring opt-in'); assert.equal(first.integrations.version, CURRENT_INTEGRATIONS_VERSION); assert.deepEqual(second, first); assert.deepEqual(input, legacyDual, 'migration must not mutate its input'); @@ -40,6 +42,77 @@ test('integration migration is versioned, deterministic, and idempotent', () => 'the independent routing migration still owns this field'); }); +test('v2 migration adds disabled deja-vu defaults without inferring opt-in', () => { + const migrated = migrateIntegrationConfig({ + integrations: { + version: 2, + hosts: { claude: true, codex: true, opencode: false }, + bindings: [], + }, + }); + + assert.equal(migrated.integrations.version, 3); + assert.deepEqual(migrated.integrations.tools.dejaVu, DEFAULT_DEJA_VU_INTENT); + assert.equal(migrated.integrations.tools.dejaVu.enabled, false); + assert.deepEqual(migrated.integrations.tools.dejaVu.hosts, []); +}); + +test('migration preserves explicit pre-release deja-vu intent and unrelated tools', () => { + const migrated = migrateIntegrationConfig({ + integrations: { + version: 2, + hosts: { claude: true, codex: true, opencode: false }, + bindings: [], + tools: { + dejaVu: { enabled: true, mode: 'auto', hosts: ['codex'], indexOnSetup: false }, + futureCompanion: { preserve: true }, + }, + }, + }); + + assert.deepEqual(migrated.integrations.tools, { + dejaVu: { enabled: true, mode: 'auto', hosts: ['codex'], indexOnSetup: false }, + futureCompanion: { preserve: true }, + }); + assert.deepEqual(migrateIntegrationConfig(structuredClone(migrated)), migrated); +}); + +test('deja-vu intent validation accepts only complete bounded intent', () => { + assert.deepEqual(validateDejaVuIntent({ + enabled: true, + mode: 'mcp', + hosts: ['claude', 'codex'], + indexOnSetup: true, + }), { + enabled: true, + mode: 'mcp', + hosts: ['claude', 'codex'], + indexOnSetup: true, + }); + + for (const [intent, message] of [ + [{ enabled: 'yes', mode: 'mcp', hosts: [], indexOnSetup: true }, /enabled must be boolean/], + [{ enabled: true, mode: 'automatic', hosts: [], indexOnSetup: true }, /mode must be one of/], + [{ enabled: true, mode: 'mcp', hosts: ['claude', 'claude'], indexOnSetup: true }, /contains duplicates/], + [{ enabled: true, mode: 'mcp', hosts: ['unknown'], indexOnSetup: true }, /unknown host/], + [{ enabled: true, mode: 'mcp', hosts: [], indexOnSetup: 'yes' }, /indexOnSetup must be boolean/], + ]) { + assert.throws(() => validateDejaVuIntent(intent), message); + } +}); + +test('future integration schema preserves deja-vu content opaquely', () => { + const future = { + integrations: { + version: CURRENT_INTEGRATIONS_VERSION + 1, + tools: { + dejaVu: { futureMode: 'ambient', hosts: { futureShape: true } }, + }, + }, + }; + assert.deepEqual(migrateIntegrationConfig(structuredClone(future)), future); +}); + test('top-level migration retires routing compatibility fields but keeps provider-axis state', () => { const migrated = migrateKitConfig({ ...structuredClone(legacyDual), diff --git a/tests/kit/settings-config.test.mjs b/tests/kit/settings-config.test.mjs index 64fb11d..6f5d6cf 100644 --- a/tests/kit/settings-config.test.mjs +++ b/tests/kit/settings-config.test.mjs @@ -83,6 +83,12 @@ test('loadKitConfig returns defaults when file missing and round-trips saves', ( const cfg = loadKitConfig(f); assert.equal(cfg.aqe, true); assert.equal(cfg.mcp.register, true); + assert.deepEqual(cfg.integrations.tools.dejaVu, { + enabled: false, + mode: 'mcp', + hosts: [], + indexOnSetup: true, + }); cfg.mcp.excludeFamilies = ['wasm']; cfg.customBlocks.push({ slug: 's', templatePath: '/t.md', detector: { type: 'always' } }); saveKitConfig(cfg, f); @@ -92,6 +98,29 @@ test('loadKitConfig returns defaults when file missing and round-trips saves', ( fs.rmSync(tmp, { recursive: true, force: true }); }); +test('loadKitConfig rejects invalid deja-vu companion intent', () => { + const cases = [ + [{ enabled: true, mode: 'ambient', hosts: [], indexOnSetup: true }, /mode must be one of/], + [{ enabled: true, mode: 'mcp', hosts: ['claude', 'claude'], indexOnSetup: true }, /contains duplicates/], + [{ enabled: true, mode: 'mcp', hosts: ['future-host'], indexOnSetup: true }, /unknown host/], + ]; + + for (const [dejaVu, message] of cases) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-deja-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ + integrations: { + version: 3, + hosts: { claude: true, codex: false, opencode: false }, + bindings: [], + tools: { dejaVu }, + }, + })); + assert.throws(() => loadKitConfig(f), message); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + test('loadKitConfig merges partial files over defaults (user file wins)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg2-')); const f = tmpFile(tmp, 'kit.json'); diff --git a/tests/kit/setup-host-flags.test.mjs b/tests/kit/setup-host-flags.test.mjs index 1ad3b61..382e7cf 100644 --- a/tests/kit/setup-host-flags.test.mjs +++ b/tests/kit/setup-host-flags.test.mjs @@ -5,7 +5,10 @@ import os from 'node:os'; import path from 'node:path'; import { applySetupHostFlags } from '../../src/lib/providers.mjs'; import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; -import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; +import { + CURRENT_INTEGRATIONS_VERSION, + defaultHostMap, +} from '../../src/lib/adapters/index.mjs'; const freshCfg = () => ({ integrations: { @@ -94,7 +97,7 @@ test('an empty config gets complete canonical envelopes and survives persistence primaryHost: 'claude', routes: {}, }); - assert.equal(cfg.integrations.version, 2); + assert.equal(cfg.integrations.version, CURRENT_INTEGRATIONS_VERSION); assert.deepEqual(cfg.integrations.hosts, { ...defaultHostMap(), codex: true }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-setup-empty-')); From d0864032d8a470b01522369c52fef094dd7292cb Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 12:01:44 -0700 Subject: [PATCH 02/19] docs: decide managed deja-vu companion boundaries --- docs/MANAGED-TOOLS.md | 30 ++- ...-capability-driven-integration-adapters.md | 19 +- ...sed-operations-and-explicit-degradation.md | 26 +- docs/adr/0035-managed-deja-vu-companion.md | 242 ++++++++++++++++++ docs/adr/README.md | 10 + docs/ddd/component-directory.md | 28 +- docs/ddd/context-map.md | 13 +- docs/ddd/integration-management.md | 49 +++- docs/ddd/ubiquitous-language.md | 8 + 9 files changed, 404 insertions(+), 21 deletions(-) create mode 100644 docs/adr/0035-managed-deja-vu-companion.md diff --git a/docs/MANAGED-TOOLS.md b/docs/MANAGED-TOOLS.md index e92be4c..b6b05f0 100644 --- a/docs/MANAGED-TOOLS.md +++ b/docs/MANAGED-TOOLS.md @@ -9,7 +9,7 @@ The **host** rows here mean execution drivers such as Claude Code, Codex CLI, an Inference **providers** such as OpenRouter and Ollama are not install-owned hosts. Provider intent may use a **binding** and native configuration **projection**, while transcripts and catalogues remain separate **observability** evidence. The shared lifecycle and value-precise ownership -design is Proposed in [ADR-0016](adr/0016-capability-driven-integration-adapters.md). +design is Accepted in [ADR-0016](adr/0016-capability-driven-integration-adapters.md). Each invariant traces to a live failure it prevents — the appendix records them. @@ -62,6 +62,7 @@ them. | **hosts** (Claude, Codex, OpenCode; OpenCode routes explicitly through `ak run`) | npm `@latest` — only when npm-managed | `ak sync` if npm-installed; **explicitly disowned** if brew/mise/native | disk: global `package.json`, else `--version` probe | npm latest for npm-managed only; external → `outdated:false` | row ✓ (version + method) / n/a / card + banner (npm-managed only) ✓ | | **agentdb** | npm, **pinned to ruflo's bundled version** — deliberately not latest | `ak sync` (repins on core skew) | disk: global `package.json` | ruflo's **bundled** copy (coherence), not npm latest — by design | row ✓ / n/a / card ✓; banner excluded (its authority isn't "latest") | | **ruvnet-brain** | npm `ruvnet-brain@latest` + `--version v` pin (never `github:` HEAD) | `ak sync`; the installer's own nightly self-updater is suppressed at install (`--no-nightly-prompt`) and disabled by sync if found (`ruvnet-brain-nightly` subsystem) | disk: KB `SOURCE.json → releaseTag`, falling back to ak's kit.json stamp for pre-stamping bundles | GitHub `releases/latest` tag (TTL-cached) | row ✓ / `V` chip ✓ / card + banner ✓ | +| **deja-vu** (opt-in companion) | npm `@vshulcz/deja-vu@latest`; v0.19.0 is the accepted contract baseline | `ak sync` only for an ak-receipted npm install; external binary/plugin installs are disowned | disk: global package plus bounded `deja version`; plugin or binary presence does not prove ownership | npm latest for owned npm; external → installed-only | content-free row / n/a / card + banner for owned npm drift | | **kit (self)** | npm, **pinned to the exact version drift saw** (`@pacphi/agentic-kit@`) | `ak sync` (runs last — npm replaces the running code) | disk: running copy's `package.json` | npm `latest` (+ `next` for prereleases, TTL-cached) | row ✓ / n/a / header version + card + banner ✓ | Statusline "n/a" cells are by design: the footer decorates the activation rows @@ -69,6 +70,30 @@ it renders (ruflo / Agentic QE / brain) — hosts, agentdb, and the kit have no footer row to decorate, and their versions live in `ak status` and the dashboard. +## Managed companion lifecycle boundary + +[ADR-0035](adr/0035-managed-deja-vu-companion.md) applies this contract to deja-vu without making +it a host, provider, routing target, or AgentDB replacement. Its lifecycle is narrower than package +presence: + +1. **Opt-in intent precedes history access.** Detection may report an external install, but no + transcript scan, index build, host wiring, or plugin adoption follows without consent. +2. **Package, target, plugin, and data ownership stay separate.** Ak updates or removes only its + receipted npm package and exact per-host targets. Upstream `wiring.json`, binary presence, and + host-plugin presence are observations, not ownership receipts. +3. **Enabled hosts select explicit targets.** Ak never delegates scope to deja-vu's `--all` or + aggregate `--auto` discovery. MCP is the default; automatic event injection is a second, + per-host consent. +4. **Indexing preserves guidance ownership.** Target installs use `--no-guidance --no-index`, then + ak runs one bounded `deja index` when required. It does not call `deja warmup`, which also writes + deja's CLI skill, and uses `index --rebuild` only for diagnosed corruption. +5. **Verification is schema- and evidence-driven.** Normal status parses + `deja doctor --json --offline` schema version 2 and independently observes host wiring/plugin + facts. Doctor exit zero alone is not health, and unknown additive fields remain compatible. +6. **Removal has three scopes.** Wiring removal is ordinary; owned package removal is explicit; + data purge is separately previewed and confirmed. Source transcripts and primary notes, + exclusions, tombstones, policy, peers, and imported history are preserved by default. + ## Where each piece lives - **npm tools** — `src/lib/versions.mjs` (`installedVersion`, `driftReport`, @@ -81,6 +106,9 @@ dashboard. `latestVersion`, `classifyDrift`, `drift`, nightly-agent detection), heals `installRuvnetBrain` / `disableRuvnetBrainNightly`. Full background on its three version namespaces and the installer's `--yes` gotcha: MAINTAINER.md. +- **deja-vu companion** — lifecycle and upstream-version boundary in + [ADR-0035](adr/0035-managed-deja-vu-companion.md); implementation follows the common adapter and + managed-version seams rather than adding a host/provider registry member. - **display surfaces** — `src/commands/status.mjs` (rows), `src/templates/statusline-footer.cjs` (chips), `src/lib/dashboard-server.mjs` (cards from the same rows; banner = diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 9600a70..cfef5eb 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -4,7 +4,7 @@ [ADR-0020](0020-ga-stable-surfaces.md); closed-registry clause superseded by [ADR-0029](0029-host-adapter-extension-point.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-25 +- **Updated:** 2026-08-26 - **Update note:** Added read-only Codex plugin-hook compatibility facts, runtime-selected Ruflo project-memory store proofs, and the non-correlatable OpenRouter account-analytics boundary; removed the pre-GA compatibility command, @@ -38,6 +38,10 @@ retires the deprecated Claude→Codex MCP projection while retaining this ADR's value-precise ownership boundary. OpenAI's Claude Code plugin remains external and user-owned; Ruflo and Agentic-QE registrations are independent Codex integrations. + 2026-08-26: [ADR-0035](0035-managed-deja-vu-companion.md) applies this lifecycle and + ownership model to an opt-in managed companion. A companion consumes host capabilities and + projects its own service into enabled hosts, but does not become a fifth adapter axis, a host, + a provider, a binding, or an observability authority. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), @@ -48,8 +52,10 @@ [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md), [ADR-0012](0012-observability.md), [ADR-0015](0015-managed-codex-native-statusline.md), + [ADR-0035](0035-managed-deja-vu-companion.md), [issue #59](https://github.com/pacphi/agentic-kit/issues/59), - [issue #71](https://github.com/pacphi/agentic-kit/issues/71) + [issue #71](https://github.com/pacphi/agentic-kit/issues/71), and + [issue #114](https://github.com/pacphi/agentic-kit/issues/114) > **GA amendment:** the capability axes and lifecycle contracts remain authoritative. Sections > that preserve compatibility exports, commands, or persisted fields are historical after 4.0. @@ -156,6 +162,14 @@ Host and provider namespaces are typed. An ID appearing on the provider axis can host reference, and vice versa. Compatibility exports may derive the old arrays and maps from the registries during migration, but they are not independent sources of truth. +#### Managed companions remain outside the adapter axes + +ADR-0035 introduces a managed companion tool without adding an adapter axis. A companion consumes +the enabled-host set and reuses the managed projection lifecycle to place an independently packaged +service into those hosts. It cannot drive a session, receive a route, establish inference identity, +or become a source of canonical observability merely because it can read host artifacts. Its +package, per-host wiring, plugin coexistence, and user data carry separate facts and receipts. + ### 2. Derive choices from capability, not identity Commands ask the registry what an adapter can do instead of comparing its ID: @@ -514,6 +528,7 @@ but less truthful. | API keys are never persisted | Section 7 and serialization tests | | Dry-run/idempotence/undo/no-clobber are shared and tested | Sections 3 and 4; conformance suite | | External plugin hooks and runtime-selected memory are truthful | Section 4; read-only plugin diagnostics and isolated memory round-trip | +| Managed companions reuse lifecycle without becoming hosts/providers | Section 1; ADR-0035 | | Issue #59 can consume the abstraction without being subsumed | Sections 5 and 8 | | Documentation uses one vocabulary | Section 9 | diff --git a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md index 4d7c2c7..4148f47 100644 --- a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md +++ b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md @@ -1,10 +1,11 @@ # ADR-0023 — Fail-closed mutations and explicit degraded operation evidence - **Status:** Implemented -- **Updated:** 2026-08-24 — issue #170 parser-yield diagnostics distinguish readable Codex roots from - readable roots whose transcript schema produces no normalized responses +- **Updated:** 2026-08-26 — ADR-0035 applies fail-closed preflight, bounded evidence, and + content-free degradation to the opt-in deja-vu companion - **Date:** 2026-08-04 -- **Previous update:** 2026-08-06 +- **Previous updates:** 2026-08-24 — issue #170 parser-yield diagnostics distinguish readable Codex + roots from readable roots whose transcript schema produces no normalized responses; 2026-08-06 - **Update note:** Generalized setup preflight into a required host-adapter trust contract, added Codex registration/OpenCode approval disclosure, documented current-UID installation-mode boundaries, and surfaced usage-source health in the dashboard UI. Closed a parity gap §7 left @@ -23,7 +24,9 @@ [ADR-0012](0012-observability.md), [ADR-0014](0014-dashboard-auth-and-remediation.md), [ADR-0016](0016-capability-driven-integration-adapters.md), and - [ADR-0017](0017-opencode-host.md) + [ADR-0017](0017-opencode-host.md), + [ADR-0035](0035-managed-deja-vu-companion.md), and + [issue #114](https://github.com/pacphi/agentic-kit/issues/114) ## Context @@ -118,6 +121,19 @@ rules survive. OpenCode discloses its user-scope wildcard approvals, MCP registr plugin, and managed host assets. Codex discloses MCP/AQE registrations while explicitly retaining its sandbox and approval policy. +#### 6.1 Sensitive companion reads and injections require their own consent + +ADR-0035 applies the same boundary to deja-vu before the first transcript scan or index write, not +only before host configuration changes. Consent names the stores read, the unencrypted derived +index, each explicit host target, and the automatic event set. MCP-only recall is the opt-in +default. Auto-recall is a second per-host consent because v0.19.0 can inject untrusted history at +prompt, compaction, command, and edit boundaries rather than only at session start. + +Companion diagnosis stays offline, bounded, schema-checked, and content-free. A zero exit from +`deja doctor --json --offline` does not upgrade a reported fault to healthy; missing auto-hook, +plugin-coexistence, Codex-trust, or index-integrity evidence remains unknown. A destructive data +purge is planned and confirmed separately from wiring or package removal. + ### 7. Usage source degradation is visible in the dashboard, for all four local sources The Usage API's `sourceHealth` field is rendered as persistent local-source pills in the @@ -221,6 +237,8 @@ unexplained. argv; shared logins and host-PID containers remain same-UID boundaries, not separate users. - `ak setup` makes each enabled host's trust changes inspectable before acceptance and detects undisclosed Claude project grants introduced by upstream initializers. +- Enabling deja-vu discloses the history read and index write before either occurs; automatic + injection and destructive data purge require separate, narrower consent. - Some formerly best-effort writes now fail. This is deliberate: when ak promises a backup, mutation without one is a correctness failure. - An unreadable `~/.claude/projects` or `~/.codex/sessions` (permissions, a corrupt filesystem entry, diff --git a/docs/adr/0035-managed-deja-vu-companion.md b/docs/adr/0035-managed-deja-vu-companion.md new file mode 100644 index 0000000..749f680 --- /dev/null +++ b/docs/adr/0035-managed-deja-vu-companion.md @@ -0,0 +1,242 @@ +# ADR-0035 — Manage deja-vu as an opt-in session-history companion + +- **Status:** Accepted; implementation tracked by + [issue #114](https://github.com/pacphi/agentic-kit/issues/114) +- **Date:** 2026-08-26 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md), + [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md), + [ADR-0026](0026-about-component-directory.md), and + [issue #114](https://github.com/pacphi/agentic-kit/issues/114) + +## Context + +Agentic-kit manages curated operational memory through Ruflo and AgentDB. That memory records +decisions, outcomes, reusable patterns, and project continuity; it is not a verbatim archive of +every host transcript. Machines consequently retain useful historical evidence—exact errors, +commands, tool output, and edit spans—that never belongs in curated project memory. + +[Issue #114](https://github.com/pacphi/agentic-kit/issues/114) proposes deja-vu as the local +session-history companion for this gap. deja-vu indexes histories already written by coding +harnesses and exposes recall through MCP and optional automatic injection. It is an evidence +archive beside curated memory, not a replacement for it. + +The issue was filed against deja-vu 0.16.7. The +[upstream author's comment](https://github.com/pacphi/agentic-kit/issues/114#issuecomment-5259487049) +confirmed the npm/package-manager boundary, target names, `--no-guidance --no-index`, and +`doctor --json --offline`; it also corrected the issue's session-start-only description because +0.17.0 added point-of-action recall through `PreToolUse`. + +The accepted baseline is now deja-vu +[v0.19.0](https://github.com/vshulcz/deja-vu/releases/tag/v0.19.0). Its +[changelog](https://github.com/vshulcz/deja-vu/blob/v0.19.0/CHANGELOG.md#0190---2026-08-26) +adds host-native packages/plugins, Codex plugin hooks, sync health in `doctor --json`, stricter +install refusal, and more accurate unreadable/corrupt reporting. Its +[JSON contract](https://github.com/vshulcz/deja-vu/blob/v0.19.0/docs/json-output.md#deja-doctor---json) +now pins `schema_version: 2` and permits additive fields within that version. These changes remove +the author's earlier unversioned-schema concern, but they make plugin coexistence and per-host hook +semantics part of the integration boundary. + +Transcript history is sensitive. Building the index reads existing local stores and writes an +unencrypted derived index. Automatic recall can place historical content immediately before a +command or file edit. Presence of a deja binary, plugin, index, or upstream wiring record proves +neither user consent nor agentic-kit ownership. + +## Decision + +### 1. Model a managed companion, not another host or memory authority + +deja-vu is the first **managed companion tool**: an opt-in tool that consumes the enabled-host set +and projects a bounded service into those hosts without becoming an execution host, inference +provider, provider binding, routing target, observability source, or AgentDB authority. + +The boundary is: + +```text +Ruflo / AgentDB deja-vu +curated operational memory local historical evidence archive +accepted decisions/patterns transcripts, commands, tool output, edit spans +promotion authority recall source only +project-scoped continuity cross-host retrospective search +``` + +Recalled history remains untrusted input. It may inform reasoning; it cannot grant permission, +override current policy, or silently promote itself into curated memory. + +### 2. Make installation and every read of history opt-in + +Existing and migrated configurations default to disabled and unowned. Enabling the companion +requires explicit interactive consent or an explicit batch option. Before mutation, the plan must +name: + +- the local transcript stores deja-vu may read; +- the unencrypted derived index it will write; +- each enabled host it will wire and the exact upstream target; +- whether wiring is MCP-only or which automatic events may inject context; +- that redaction is best-effort rather than a secrecy guarantee; +- that embeddings and cross-machine sync remain disabled unless the user configures them outside + this decision; and +- which data uninstall preserves and which separate purge would delete. + +MCP-only recall is the default after opt-in. Automatic recall is a second consent, recorded per +host. A single global `auto` label is insufficient for disclosure because v0.19.0 host +capabilities differ: + +| Agentic-kit host | MCP target | Auto target | v0.19.0 automatic events relevant to consent | +|---|---|---|---| +| Claude | `claude-code` | `claude-auto` | session start, prompt submit, pre-compaction, pre-tool command/edit, failed-command follow-up | +| Codex | `codex` | `codex-auto` | session start, pre-tool `Bash`/`apply_patch`, failed-command follow-up | +| OpenCode | `opencode` | `opencode-auto` | session context, per-prompt recall, pre-compaction; no `PreToolUse` | + +The target map is version-bounded data. Agentic-kit invokes only explicit targets for enabled +hosts; it never uses upstream `install --all`, `install --auto`, or `uninstall --all`, because those +commands discover and mutate unrelated harnesses on the machine. + +### 3. Use the shared lifecycle with bounded subprocesses + +The companion implements ADR-0016's lifecycle: + +```text +detect -> plan -> apply -> verify -> undo +``` + +- `detect` reads desired intent, package/install facts, ownership receipts, bounded binary output, + offline doctor output, host wiring, plugin presence, and index metadata. It never warms, + refreshes, installs, or repairs. +- `plan` deterministically selects package, explicit target transition, one optional index run, + verification, and undo operations. It separates ordinary removal from destructive data purge. +- `apply` installs the released npm artifact, invokes each target with + `--no-guidance --no-index`, performs one bounded `deja index` when required, and records + ownership only after independent success evidence. +- `verify` recollects disk/package, doctor, host-wiring, plugin, trust, and index facts. An apply + result is never its own proof. +- `undo` removes exact receipt-owned targets in reverse dependency order and removes the npm + package only when agentic-kit owns it. Partial undo keeps its receipt and returns failure. + +Every subprocess has a timeout, bounded output, process-tree cleanup, and redacted diagnostics. +Status, logs, receipts, and Dashboard projections never include transcript text, queries, recalled +content, or raw project paths. + +The managed path does not call `deja warmup`: v0.19.0's warmup command also writes deja's CLI +skill, which would cross agentic-kit's `--no-guidance` ownership boundary. Initial and stale-index +refresh uses one bounded `deja index` after all targets converge. `deja index --rebuild` is reserved +for a diagnosed corruption repair, is disclosed separately, and is never routine sync work. + +### 4. Pin the upstream machine contract at the boundary + +The managed npm coordinate is `@vshulcz/deja-vu`; the executable is `deja`. Agentic-kit owns +updates only for an npm installation it installed and receipted. It uses the package manager for +install, update, and removal and never calls `deja update` for that installation. + +Normal health collection invokes: + +```text +deja doctor --json --offline +``` + +The parser accepts only supported schema versions, initially exactly `2`, while ignoring unknown +additive fields. It classifies timeout, non-JSON output, missing/unsupported schema, and unknown +enum values as degraded or unknown. Doctor's ordinary exit status does not establish health; the +body does. `sync` is an expected additive v0.19.0 top-level object. + +Doctor JSON supplies store, index, MCP, sqlite, version, policy, ingest, embedding, sync, and +optional deep facts. It does not fully prove auto-hook state, Codex hook trust, or every index +integrity condition that the human doctor checks. Agentic-kit therefore combines doctor with +bounded, content-free observation of the configured host surfaces. Missing evidence stays +unknown. An existing empty upstream store and a missing store both report `missing`; agentic-kit +does not invent a distinction unless an independent bounded filesystem observation proves it. + +### 5. Keep package, wiring, plugin, and data ownership separate + +Agentic-kit records separate value-precise receipts for: + +1. the npm package installation; +2. each host's exact upstream target; +3. any agentic-kit-written intent; and +4. no user data—the index, notes, exclusions, tombstones, policies, imported history, and source + transcripts remain user-owned. + +Upstream `$XDG_CONFIG_HOME/deja/wiring.json` contains target/version/home/executable repair intent. +It is useful evidence but is not an agentic-kit ownership receipt: it does not record prior values +or package ownership and can change after partially successful upstream operations. Binary or +plugin presence is likewise not ownership. + +Externally installed npm, Homebrew, Go, native binary, and host-plugin integrations remain visible +and unowned. Agentic-kit may report them, but does not update, adopt, rewrite, or remove them without +an explicit ownership transition. + +v0.19.0 plugin coexistence must be observed rather than assumed. In particular, the Codex plugin +provides session-start, per-prompt, and pre-compaction hooks, while `codex-auto` supplies +session-start, pre-tool, and failed-command hooks; the plugin stands down when local deja hooks +exist. Agentic-kit must disclose or refuse a transition that would silently remove the plugin's +per-prompt/pre-compaction behavior. A wired but untrusted or disabled Codex hook is not healthy. + +### 6. Preserve data by default and bound destructive purge + +Ordinary uninstall removes receipt-owned wiring only. Package removal is a separate explicit scope. +Data purge is a third, destructive scope with a preview and confirmation. + +Agentic-kit resolves the index from validated observed configuration—preferably +`doctor.index.path`—rather than assuming XDG cache behavior. v0.19.0 defaults to +`~/.cache/deja/index.db` unless `DEJA_INDEX_DIR` is set; `XDG_CACHE_HOME` does not relocate it. +Notes resolve independently through `DEJA_NOTES_FILE` or XDG/platform data paths. + +A purge may delete only canonical, absolute, known companion artifacts under an exact allowlist. +It rejects empty, relative, root, home, host-store, or unexpectedly broad targets; follows no +unresolved glob; and never deletes source transcripts. Deleting the index can also delete +imported-only indexed material and the index's tombstone mirror, so the preview states that loss. +Primary notes, exclusions, tombstones, policy, peers, and imported history are preserved unless a +more specific future decision defines and consents to their deletion. + +### 7. Keep drift and status content-free + +Normalized companion facts distinguish: + +- disabled, absent, externally present, and agentic-kit-owned; +- install method, installed version, latest version, and package drift; +- desired and observed target per enabled host; +- MCP wiring, auto event coverage, plugin coexistence, and trust state; +- doctor schema/availability, source qualifiers, and index missing/stale/healthy/unknown; and +- usable-but-degraded versus failed operations. + +`ak sync` repairs only receipt-owned or explicitly adopted state and recollects facts to prove +convergence. Repeated sync is a no-op. No drift surface prints content or converts doctor exit zero +into a healthy verdict. + +## Consequences + +- Users gain cross-host historical recall without diluting curated project memory. +- Setup and first indexing cost time and local disk; both remain opt-in and visible. The managed + index command does not install deja-authored guidance. +- MCP-first adoption minimizes ambient context injection. +- Auto mode needs more disclosure and testing because its event surface differs by host and can + change through plugin coexistence. +- Agentic-kit must maintain a small versioned anti-corruption layer for doctor schema and target + capabilities rather than parsing human output as an open-ended API. +- Default uninstall leaves user history intact. Purge is deliberately harder because a broad cache + deletion can destroy imported evidence or privacy state. +- External installations remain useful but cannot converge through `ak sync` until explicitly + adopted. + +## Rejected alternatives + +- **Treat deja-vu as a host, provider, or AgentDB replacement:** collapses distinct authority and + routing boundaries. +- **Enable it by default:** scans sensitive history without consent. +- **Default to auto-recall:** injects untrusted historical content at host-specific action points. +- **Use upstream aggregate install/uninstall flags:** mutates detected harnesses outside + agentic-kit's enabled-host intent and receipts. +- **Trust doctor exit status or upstream wiring records as proof:** loses degraded and partial + states. +- **Call `deja update` for npm installs:** creates competing update owners. +- **Delete the deja directory on uninstall:** conflates wiring, package, derived index, and + user-owned primary data. + +## References + +- [Agentic-kit issue #114](https://github.com/pacphi/agentic-kit/issues/114) +- [Upstream author clarification](https://github.com/pacphi/agentic-kit/issues/114#issuecomment-5259487049) +- [deja-vu v0.19.0 release](https://github.com/vshulcz/deja-vu/releases/tag/v0.19.0) +- [deja-vu v0.19.0 changelog](https://github.com/vshulcz/deja-vu/blob/v0.19.0/CHANGELOG.md#0190---2026-08-26) +- [deja-vu JSON output contract](https://github.com/vshulcz/deja-vu/blob/v0.19.0/docs/json-output.md) +- [deja-vu security model](https://github.com/vshulcz/deja-vu/blob/v0.19.0/docs/SECURITY-MODEL.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 3436810..42c8629 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,6 +42,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0032](0032-model-lifecycle-intelligence.md) | Model lifecycle intelligence from provenance-aware local evidence | Implemented | | [0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) | Retire Codex MCP; bound reciprocal QE-Court participant transport | Implemented; handoff transport amended by 0034 | | [0034](0034-schema-native-handoffs-and-hermetic-seats.md) | Schema-native worker handoffs and hermetic qe-court seats | Implemented | +| [0035](0035-managed-deja-vu-companion.md) | Manage deja-vu as an opt-in session-history companion | Accepted; implementation tracked by issue #114 | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -239,3 +240,12 @@ read-only. Canonical route mutation remains `ak host pick`, first-party migratio from quality, and only Route Intelligence may claim evidence-backed equivalence. The feature adds `ak models`, a cache-only status row, an Overview summary, and Models beneath Usage without changing the Dashboard's five primary areas. + +**0035** accepts deja-vu v0.19.0 as an opt-in managed companion for local cross-host session +history. It keeps Ruflo/AgentDB authoritative for curated operational memory, defaults recall to +MCP, requires separate per-host consent for automatic injection, and applies the shared +detect/plan/apply/verify/undo lifecycle without making the companion a host, provider, binding, +routing target, or observability authority. Package, target, plugin, and data ownership stay +separate; normal diagnosis parses offline doctor schema version 2; indexing uses bounded +`deja index` rather than guidance-writing `deja warmup`; teardown preserves external installs and +user data unless a separately previewed purge is confirmed. diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md index 70f06d1..c03bdc6 100644 --- a/docs/ddd/component-directory.md +++ b/docs/ddd/component-directory.md @@ -4,7 +4,8 @@ This document specifies the domain decided by [ADR-0026](../adr/0026-about-component-directory.md) and implemented in `src/lib/dashboard/about-directory.mjs`. Its terms are merged into [Ubiquitous language](ubiquitous-language.md) and the context is on the -[context map](context-map.md). +[context map](context-map.md). [ADR-0035](../adr/0035-managed-deja-vu-companion.md) adds the first +managed companion to the directory's parity boundary. ## Purpose @@ -26,9 +27,9 @@ The neighboring contexts each own a different kind of fact about the same compon owns the only kind none of them can: **editorial identity**. - **[Integration management](integration-management.md)** owns capability registries, bindings, - and ownership — what a component *can do* and what ak *manages about it*. It has no voice: no - value proposition, no links for a human, no reading order. The directory borrows its registry - as a parity gate (every managed tool must have an entry) and borrows nothing else. + companions, and ownership — what a component *can do* and what ak *manages about it*. It has no + voice: no value proposition, no links for a human, no reading order. The directory borrows its + registry as a parity gate (every managed tool must have an entry) and borrows nothing else. - **Overview / status** owns health verdicts. The directory renders a version chip from the same detection facts, but a card is an introduction, not a verdict — About never says "degraded," it says what the thing is for and where to read more. @@ -125,6 +126,12 @@ paragraph explains what was configured and why it helps, and each names the comm manages it (`ak host`, `ak sync`, `ak x mcp pick`) so "yours to change" is actionable, not a platitude. +A managed companion receives one packaged-tool entry even though its service spans several hosts. +Its prose states purpose and the curated-memory boundary; it never claims that history is indexed, +wiring is trusted, or automatic recall is active. Those remain independently joined detection +chips. External/plugin-only presence may render as unowned, but it does not create a second card or +authorize the directory to adopt it. + ## Delivery No new endpoint. The directory module is imported by the page/client the same way `groups.mjs` @@ -161,12 +168,11 @@ takes the worst of the pair, so the quieter one's health cannot hide behind the no endpoint, and a failed status join degrades chips to `unknown` without hiding cards. 3. **Prose never claims runtime state.** Installed/version/configured render exclusively as chips fed by detection; the paragraph reads true on any machine. -4. **Registry↔directory parity is a test, scoped to built-in adapters.** Every managed tool - shipped in the built-in registry has exactly one entry; no entry exists for something ak - neither installs nor configures. There is no dynamic or third-party host concept yet, so - today "built-in" and "the registry" are the same set; an externally-admitted adapter is - exempt from this parity gate until the wave-4 adapter-extension contract graduates it to a - card-carrying citizen. +4. **Registry↔directory parity is a test, scoped to built-in adapters and companions.** Every + managed tool shipped in the built-in registries has exactly one entry; no entry exists for + something ak neither installs nor configures. An experimental externally admitted adapter is + exempt from this parity gate until the adapter-extension contract graduates it to a + card-carrying citizen; external companion plugins remain observations, not directory entries. 5. **Links are `https`, named-host, user-initiated**; the kit fetches none of them; all are covered by the nightly external link sweep. 6. **Official marks only where genuinely official and already shipped**; everything else is an @@ -199,11 +205,13 @@ normative and this table restates it for readers of this document. | Register contract | The editorial writing rules (one ~50-word paragraph, plain language, no runtime claims, no superlatives) | | Parity gate | The test asserting managed-tools registry ↔ directory completeness in both directions | | Configured surface | A non-package thing ak sets up, carrying a managing command instead of package links | +| Managed companion entry | One packaged-tool entry whose chips may join package, wiring, plugin/trust, and data-health facts without exposing content | ## References - [ADR-0026](../adr/0026-about-component-directory.md) — the decision record this domain implements - [Integration management](integration-management.md) — the registry this stays in parity with +- [ADR-0035](../adr/0035-managed-deja-vu-companion.md) — the managed companion boundary - [Machine footprint](machine-footprint.md) — the measurement context this deliberately isn't - [Context map](context-map.md) — where this context sits - [Dashboard guide](../DASHBOARD.md) diff --git a/docs/ddd/context-map.md b/docs/ddd/context-map.md index a0c7592..93ab0e1 100644 --- a/docs/ddd/context-map.md +++ b/docs/ddd/context-map.md @@ -7,7 +7,8 @@ identify who owns each decision and where translation is required. Configuration Intent | +----> Integration Management ----> Native Configuration Surfaces - | | + | | | + | | +----> Managed Companion Surfaces | +----> Routing and Orchestration | Native Evidence ----> Evidence Acquisition ----> Canonical Evidence @@ -39,8 +40,10 @@ does not prove that an executable, credential, or endpoint is usable. ### Integration management Owns the registries, capabilities, binding validation, normalized integration facts, managed -projection lifecycle, config migration, and value-precise ownership. Native JSON, TOML, environment, -and CLI surfaces are downstream representations. +projection lifecycle, companion lifecycle specialization, config migration, and value-precise +ownership. Native JSON, TOML, environment, CLI, and companion surfaces are downstream +representations. A managed companion consumes enabled-host identity but gains no host, provider, +routing, observability, or curated-memory authority. See [Integration management](integration-management.md). @@ -147,6 +150,7 @@ and credential policy is distinct from the offline-first dashboard and integrati |----------|------------|--------------| | Configuration intent | Integration management | Desired state; detection and verification remain independent | | Integration management | Native surfaces | Configuration projections with ownership receipts | +| Integration management | Managed companion surfaces | Opt-in package and explicit per-host projections; plugin/data ownership stays separate | | Integration management | Routing and orchestration | Capability-qualified host and binding facts | | Native evidence | Evidence acquisition | Source-specific anti-corruption adapters | | Evidence acquisition | Observability | Versioned canonical events | @@ -211,3 +215,6 @@ observed before the split was made explicit. - Component directory authors identity, it does not observe it. Editorial prose never asserts runtime state; installed, version, and configured render exclusively as chips fed by detection facts borrowed from existing collectors. +- Managed companion surfaces stay downstream of Integration management. Their ability to read host + history or inject recalled context does not make them hosts, evidence owners, or policy + authorities; consent, content-free observation, and ownership-safe teardown remain upstream. diff --git a/docs/ddd/integration-management.md b/docs/ddd/integration-management.md index 8fa2b38..4398d5c 100644 --- a/docs/ddd/integration-management.md +++ b/docs/ddd/integration-management.md @@ -1,7 +1,9 @@ # Integration Management Domain This document describes the integration model implemented by -[ADR-0016](../adr/0016-capability-driven-integration-adapters.md) and `src/lib/adapters/`. +[ADR-0016](../adr/0016-capability-driven-integration-adapters.md), extended with the managed +companion boundary accepted by [ADR-0035](../adr/0035-managed-deja-vu-companion.md), and +`src/lib/adapters/`. ## Purpose @@ -25,6 +27,13 @@ Host -------- ProviderBinding -------- InferenceProvider +-- ConfigurationProjection +-- ObservabilitySource(s) +-- host capabilities + +ManagedCompanion ---- consumes enabled Host ids + | + +-- independently owned package + +-- per-host companion projection + +-- content-free health facts + +-- user-owned companion data ``` The four built-in adapter registries are validated code. ADR-0029 additionally admits an @@ -56,6 +65,18 @@ returns a binding only when the supplied criteria identify exactly one candidate Endpoints reject embedded credentials, fragments, secret-bearing query parameters, unsupported protocols, and non-loopback plaintext HTTP. +### Managed companion + +A managed companion is an opt-in, independently packaged tool that projects a bounded service into +enabled hosts. It consumes host identity and capabilities without becoming a host, provider, +provider binding, routing target, or observability authority. deja-vu is the first companion: it +provides a local historical-evidence archive beside Ruflo/AgentDB's curated operational memory. + +Companion intent records enablement, selected hosts, and per-host service/injection choice. +Companion facts keep package presence and ownership, explicit target wiring, external plugin +coexistence, trust, schema compatibility, and data health separate. User history and companion data +never become agentic-kit-owned because ak invoked the indexer. + ## Capability rules - Primary and activity-routing capabilities require session-driving capability. @@ -70,6 +91,8 @@ protocols, and non-loopback plaintext HTTP. Model lifecycle collector selects model-discovery descriptors and dispatches only to explicit built-in source adapters. External hosts without a supported catalogue descriptor remain `unsupported`. +- A managed companion consumes only enabled hosts and explicit target capabilities. It gains no + host, provider, routing, or memory-promotion authority from that relationship. ## Integration facts @@ -80,6 +103,7 @@ HostFact = present + enabled + version + auth/wiring evidence ProviderFact = configured + reachable + billing + credential presence BindingFact = host + provider + model + billing + provenance + reachability ExecutionFact = observed host + provider + model + transport + billing +CompanionFact = intent + package/ownership + per-host target/events + plugin/trust + data health ``` Presence, enablement, authentication, configuration, and reachability are independent. Missing @@ -123,6 +147,21 @@ detect -> plan -> apply -> verify -> undo Malformed, unavailable, or unsupported surfaces yield diagnostics and unknown facts rather than guessed success. +### Companion lifecycle specialization + +A companion reuses the same lifecycle with four extra boundaries: + +- history access and initial indexing require opt-in consent before mutation; +- package, per-host projection, plugin, and data ownership are independent; +- host scope is an explicit target map, never delegated to upstream machine-wide discovery; and +- verification combines a versioned machine contract with independent, content-free host + observations because one source does not prove all hook, trust, and integrity facts. + +For deja-vu v0.19.0, normal diagnosis is `deja doctor --json --offline` with JSON schema version 2. +Target application uses `--no-guidance --no-index`, followed when required by one bounded +`deja index`; `deja warmup` is excluded because it also writes deja-owned CLI guidance, and +`index --rebuild` is reserved for diagnosed corruption. + ## Ownership and drift Presence is not ownership. External installations and pre-existing configuration remain usable but @@ -133,6 +172,11 @@ User or external drift is preserved. Receipts never authorize deletion of sibling keys, containing tables, external executables, or credential values. +For a companion, an upstream wiring ledger is observation rather than an ak receipt. Ordinary undo +removes exact receipt-owned projections; package removal is a separate scope; data purge is a third, +destructive scope. External packages/plugins and user-owned index, notes, policy, privacy state, +imports, and source transcripts are preserved unless narrower consent explicitly says otherwise. + ## Configuration migration `integrations.version` identifies the canonical integration envelope. Loading configuration @@ -171,3 +215,6 @@ envelopes and never reads the retired paths. 11. Catalogue descriptor identity alone executes nothing and proves neither entitlement nor routability. 12. Model lifecycle collection cannot mutate integration intent or native projections. +13. A managed companion cannot become a host, provider, binding, routing target, observability + authority, or curated-memory promotion authority. +14. Companion health and teardown never expose or infer ownership of transcript content. diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index 72993c8..ed40374 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -20,6 +20,11 @@ contradictory meaning. | Capability | Explicit behavior supported by an adapter; identity alone never implies it | | Integration intent | Desired, persisted host and binding configuration | | Integration facts | Immutable normalized observations about hosts, providers, and bindings | +| Managed companion | Opt-in tool that projects a bounded service into enabled hosts without becoming a host, provider, binding, routing target, or memory authority | +| Companion intent | Persisted enablement, selected-host, and per-host service/injection choice for a managed companion | +| Companion fact | Content-free observation of companion package ownership, target wiring, plugin/trust state, schema compatibility, and data health | +| Companion projection | Exact host-native wiring by which a companion exposes MCP or consented automatic events to one enabled host | +| Auto-recall | Explicitly consented injection of untrusted historical context at a named host event; not a uniform session-start capability | ## State and evidence language @@ -37,6 +42,7 @@ contradictory meaning. | Unknown | The available evidence cannot establish a value; it does not mean false, zero, free, absent, or unreachable | | Ownership receipt | Exact record of a value written by `ak`, permitting narrow undo only while that value is unchanged | | Drift | Current state differs from the last value written or expected by `ak` | +| Companion data | User-owned index, notes, privacy state, imports, and source transcripts; invoking a managed companion does not transfer ownership to `ak` | Billing is a fact about a credentialed access path or observed execution, not an immutable vendor identity. A vendor may support subscription-backed host login and metered API-key use. Local @@ -186,6 +192,8 @@ runtime state is a chip word, never a prose word. See - Say **compatible candidate** only when required mechanical facts are established. Reserve **cheaper equivalent** and **premium justified** for Route Intelligence evidence. - Do not infer an inference provider from a transcript host alone. +- Do not call a managed companion a host, memory authority, or observability source. Name the exact + companion projection or automatic event when injection behavior matters. - Do not replace an unknown fact with a convenient default. - Say **System** for the dashboard area and the command; say **Machine footprint** only for the bounded context and its module directory. No user-facing string says "footprint". From 46faeafb4558c0f72409e8dde1a6736a30cb636b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 12:02:12 -0700 Subject: [PATCH 03/19] feat(deja-vu): normalize v0.19 companion facts --- src/lib/deja-vu.mjs | 307 ++++++++++++++++++ src/lib/exec.mjs | 9 +- tests/kit/deja-vu.test.mjs | 223 +++++++++++++ tests/kit/exec.test.mjs | 23 ++ tests/kit/fixtures/deja-vu/doctor-v2.json | 55 ++++ .../fixtures/deja-vu/install-help-v0.19.0.txt | 9 + 6 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 src/lib/deja-vu.mjs create mode 100644 tests/kit/deja-vu.test.mjs create mode 100644 tests/kit/fixtures/deja-vu/doctor-v2.json create mode 100644 tests/kit/fixtures/deja-vu/install-help-v0.19.0.txt diff --git a/src/lib/deja-vu.mjs b/src/lib/deja-vu.mjs new file mode 100644 index 0000000..3a7aa40 --- /dev/null +++ b/src/lib/deja-vu.mjs @@ -0,0 +1,307 @@ +// deja-vu v0.19 is an independently evolving CLI. This module is the narrow +// anti-corruption boundary between its machine output/commands and ak's +// lifecycle. Only bounded enums, counts, and validated versions leave it; +// upstream paths, errors, policy text, peer names, and transcript metadata do +// not become integration facts. +import fs from 'node:fs'; +import path from 'node:path'; + +export const DEJA_VU_PACKAGE = '@vshulcz/deja-vu'; +export const DEJA_VU_MIN_VERSION = '0.19.0'; +export const DEJA_VU_BIN = 'deja'; +export const DEJA_VU_DOCTOR_SCHEMA_VERSION = 2; + +export const DEJA_VU_TARGETS = Object.freeze({ + claude: Object.freeze({ mcp: 'claude-code', auto: 'claude-auto' }), + codex: Object.freeze({ mcp: 'codex', auto: 'codex-auto' }), + opencode: Object.freeze({ mcp: 'opencode', auto: 'opencode-auto' }), +}); + +const REQUIRED_TARGETS = Object.freeze(Object.values(DEJA_VU_TARGETS) + .flatMap(({ mcp, auto }) => [mcp, auto])); +/** @type {ReadonlySet} */ +const REQUIRED_TARGET_SET = new Set(REQUIRED_TARGETS); +const STORE_STATES = Object.freeze([ + 'ok', 'missing', 'unreadable', 'parsed-zero', 'denied', + 'needs-sqlite3', 'needs-zstd', 'unplugged', +]); +const INDEX_STATES = Object.freeze(['missing', 'ok', 'stale', 'stale-readonly']); +const MCP_STATES = Object.freeze(['config-missing', 'not-wired', 'wired']); +const SQLITE_STATES = Object.freeze(['missing', 'ok']); +const VERSION_STATES = Object.freeze([ + 'ok', 'update-available', 'ahead', 'dev', 'offline', 'unknown', +]); +const POLICY_STATES = Object.freeze(['default', 'active', 'unreadable']); +const SYNC_STATES = Object.freeze(['ok', 'unreadable']); +const VERSION_VALUE = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const MAX_JSON_BYTES = 1024 * 1024; + +/** + * @typedef {object} RawDoctorEnvelope + * @property {number} schema_version + * @property {Array<{name:string,state:string,files:number,indexed_sessions:number,partial?:boolean,unchecked?:boolean}>} stores + * @property {{state:string,stale_stores:number}} index + * @property {Array<{name:string,state:string}>} mcp + * @property {{state:string}} sqlite3 + * @property {{state:string,current:string}} version + * @property {{state:string,indexed_sessions:number,activations:Record}} policy + * @property {{state:string,peers:Array<{host:string,sessions_from_there:number,last_error?:string,stamped_ahead?:boolean}>}} sync + */ + +/** @returns {string[]} Kit's explicit target allowlist, never upstream's full list. */ +export function requiredDejaVuTargets() { + return [...REQUIRED_TARGETS]; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function nonNegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function degraded(reason, facts = null) { + return deepFreeze({ state: 'degraded', reason, facts }); +} + +function normalizeState(value, allowed, unsupported) { + if (allowed.includes(value)) return value; + unsupported.found = true; + return 'unknown'; +} + +/** @param {Record} raw @returns {raw is RawDoctorEnvelope} */ +function validDoctorShape(raw) { + if (!Array.isArray(raw.stores) + || !isRecord(raw.index) + || !Array.isArray(raw.mcp) + || !isRecord(raw.sqlite3) + || !isRecord(raw.version) + || !isRecord(raw.policy) + || !isRecord(raw.policy.activations) + || !isRecord(raw.sync) + || !Array.isArray(raw.sync.peers)) return false; + if (typeof raw.index.state !== 'string' || !nonNegativeInteger(raw.index.stale_stores) + || typeof raw.sqlite3.state !== 'string' + || typeof raw.version.state !== 'string' || typeof raw.version.current !== 'string' + || !VERSION_VALUE.test(raw.version.current) + || typeof raw.policy.state !== 'string' || !nonNegativeInteger(raw.policy.indexed_sessions) + || typeof raw.sync.state !== 'string') return false; + for (const activation of ['search', 'mcp', 'auto']) { + const value = raw.policy.activations[activation]; + if (!isRecord(value) || !nonNegativeInteger(value.withheld)) return false; + } + for (const store of raw.stores) { + if (!isRecord(store) || typeof store.name !== 'string' || typeof store.state !== 'string' + || !nonNegativeInteger(store.files) || !nonNegativeInteger(store.indexed_sessions) + || (store.partial !== undefined && typeof store.partial !== 'boolean') + || (store.unchecked !== undefined && typeof store.unchecked !== 'boolean')) return false; + } + for (const entry of raw.mcp) { + if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.state !== 'string') { + return false; + } + } + for (const peer of raw.sync.peers) { + if (!isRecord(peer) || typeof peer.host !== 'string' + || !nonNegativeInteger(peer.sessions_from_there) + || (peer.last_error !== undefined && typeof peer.last_error !== 'string') + || (peer.stamped_ahead !== undefined && typeof peer.stamped_ahead !== 'boolean')) return false; + } + return true; +} + +/** + * Parse `deja doctor --json --offline`. The return value is non-throwing and + * contains no upstream path, error, peer, policy-rule, or transcript string. + * Unknown enum values are converted to the controlled value `unknown` and + * mark the compatible envelope degraded instead of echoing the new value. + * @param {unknown} input + */ +export function parseDejaVuDoctor(input) { + let raw = input; + if (typeof input === 'string') { + if (Buffer.byteLength(input, 'utf8') > MAX_JSON_BYTES) return degraded('json-too-large'); + try { raw = JSON.parse(input); } catch { return degraded('json-malformed'); } + } + if (!isRecord(raw)) return degraded('envelope-invalid'); + if (!Object.hasOwn(raw, 'schema_version')) return degraded('schema-missing'); + if (raw.schema_version !== DEJA_VU_DOCTOR_SCHEMA_VERSION) { + return degraded('schema-unsupported'); + } + if (!validDoctorShape(raw)) return degraded('shape-invalid'); + + const unsupported = { found: false }; + const storeStates = Object.fromEntries([...STORE_STATES, 'unknown'].map((state) => [state, 0])); + let partial = 0; + let unchecked = 0; + for (const store of raw.stores) { + const state = normalizeState(store.state, STORE_STATES, unsupported); + storeStates[state]++; + if (store.partial === true) partial++; + if (store.unchecked === true) unchecked++; + } + + const targets = Object.fromEntries(REQUIRED_TARGETS.map((target) => [target, 'unknown'])); + let unknownTargets = 0; + for (const entry of raw.mcp) { + const state = normalizeState(entry.state, MCP_STATES, unsupported); + if (Object.hasOwn(targets, entry.name)) targets[entry.name] = state; + else unknownTargets++; + } + + const facts = { + schemaVersion: DEJA_VU_DOCTOR_SCHEMA_VERSION, + stores: { total: raw.stores.length, states: storeStates, partial, unchecked }, + index: { + state: normalizeState(raw.index.state, INDEX_STATES, unsupported), + staleStores: raw.index.stale_stores, + }, + mcp: { targets, unknownTargets }, + sqlite3: { state: normalizeState(raw.sqlite3.state, SQLITE_STATES, unsupported) }, + version: { + state: normalizeState(raw.version.state, VERSION_STATES, unsupported), + current: raw.version.current, + }, + policy: { + state: normalizeState(raw.policy.state, POLICY_STATES, unsupported), + indexedSessions: raw.policy.indexed_sessions, + withheld: Object.fromEntries(['search', 'mcp', 'auto'] + .map((name) => [name, raw.policy.activations[name].withheld])), + }, + sync: { + state: normalizeState(raw.sync.state, SYNC_STATES, unsupported), + peerCount: raw.sync.peers.length, + peersWithErrors: raw.sync.peers.filter((peer) => peer.last_error !== undefined).length, + peersAhead: raw.sync.peers.filter((peer) => peer.stamped_ahead === true).length, + }, + }; + return unsupported.found + ? degraded('value-unsupported', facts) + : deepFreeze({ state: 'ok', reason: null, facts }); +} + +/** + * Extract only the Kit-required names from the dedicated `targets:` help + * block. Mentions in examples or prose do not count as capabilities. + * @param {unknown} output + */ +export function parseDejaVuInstallHelp(output) { + const found = new Set(); + if (typeof output === 'string') { + const lines = output.split(/\r?\n/); + const start = lines.findIndex((line) => /^\s*targets:\s*$/i.test(line)); + for (let i = start + 1; start >= 0 && i < lines.length; i++) { + const line = lines[i]; + if (!/^\s+/.test(line)) break; + const value = line.trim(); + if (!value) break; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*(?:,\s*[a-z0-9]+(?:-[a-z0-9]+)*)*,?$/.test(value)) break; + for (const target of value.split(',').map((part) => part.trim()).filter(Boolean)) { + if (REQUIRED_TARGET_SET.has(target)) found.add(target); + } + } + } + const requiredTargets = [...REQUIRED_TARGETS]; + const missingTargets = requiredTargets.filter((target) => !found.has(target)); + return deepFreeze({ supported: missingTargets.length === 0, requiredTargets, missingTargets }); +} + +function dejaVuTarget(host, mode) { + const target = DEJA_VU_TARGETS[host]?.[mode]; + if (!target) throw new TypeError('unsupported deja-vu host or mode'); + return target; +} + +/** @param {string} host @param {string} mode */ +export function buildDejaVuInstallCommand(host, mode) { + return deepFreeze({ + command: DEJA_VU_BIN, + args: ['install', dejaVuTarget(host, mode), '--no-guidance', '--no-index'], + }); +} + +/** @param {string} host @param {string} mode */ +export function buildDejaVuUninstallCommand(host, mode) { + return deepFreeze({ command: DEJA_VU_BIN, args: ['uninstall', dejaVuTarget(host, mode)] }); +} + +function within(candidate, root, pathImpl) { + const rel = pathImpl.relative(root, candidate); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${pathImpl.sep}`) && !pathImpl.isAbsolute(rel)); +} + +function overlaps(left, right, pathImpl) { + return within(left, right, pathImpl) || within(right, left, pathImpl); +} + +function canonicalPath(candidate, pathImpl, realpathFn) { + let cursor = pathImpl.resolve(candidate); + const tail = []; + for (;;) { + try { + return pathImpl.join(realpathFn(cursor), ...tail); + } catch { + const parent = pathImpl.dirname(cursor); + if (parent === cursor) return pathImpl.resolve(candidate); + tail.unshift(pathImpl.basename(cursor)); + cursor = parent; + } + } +} + +/** + * Validate the derived index location before a destructive operation. The + * default allow-root deliberately follows deja-vu, not XDG_CACHE_HOME. + * Rejections return controlled reason codes and never echo the candidate. + * @param {unknown} candidate + * @param {{homeDir?:string,allowedRoots?:string[],sourceRoots?:string[],configRoots?:string[],pathImpl?:typeof path,realpathFn?:(value:string)=>string}} options + */ +export function validateDejaVuIndexPath(candidate, options = {}) { + const pathImpl = options.pathImpl ?? path; + const realpathFn = options.realpathFn ?? fs.realpathSync.native; + const reject = (reason) => deepFreeze({ ok: false, reason }); + if (typeof candidate !== 'string' || candidate.includes('\0')) return reject('path-invalid'); + if (typeof options.homeDir !== 'string' || !pathImpl.isAbsolute(options.homeDir) + || !pathImpl.isAbsolute(candidate)) return reject('path-not-absolute'); + const home = canonicalPath(options.homeDir, pathImpl, realpathFn); + const indexPath = canonicalPath(candidate, pathImpl, realpathFn); + if (pathImpl.basename(indexPath) !== 'index.db') return reject('path-not-index'); + + const requestedRoots = options.allowedRoots ?? [pathImpl.join(options.homeDir, '.cache', 'deja')]; + if (!Array.isArray(requestedRoots) || requestedRoots.length === 0 + || requestedRoots.some((root) => typeof root !== 'string' || !pathImpl.isAbsolute(root))) { + return reject('allow-root-invalid'); + } + const allowedRoots = requestedRoots.map((root) => canonicalPath(root, pathImpl, realpathFn)); + const volumeRoot = pathImpl.parse(indexPath).root; + if (allowedRoots.some((root) => root === volumeRoot || root === home || within(home, root, pathImpl))) { + return reject('allow-root-too-broad'); + } + if (!allowedRoots.some((root) => within(indexPath, root, pathImpl) && indexPath !== root)) { + return reject('path-outside-allow-root'); + } + + const defaultForbidden = [ + pathImpl.join(options.homeDir, '.config', 'deja'), + pathImpl.join(options.homeDir, '.claude'), + pathImpl.join(options.homeDir, '.codex'), + pathImpl.join(options.homeDir, '.config', 'opencode'), + ]; + const extraForbidden = [...(options.sourceRoots ?? []), ...(options.configRoots ?? [])]; + const forbidden = [...defaultForbidden, ...extraForbidden] + .filter((root) => typeof root === 'string' && pathImpl.isAbsolute(root)) + .map((root) => canonicalPath(root, pathImpl, realpathFn)); + if (forbidden.some((root) => overlaps(indexPath, root, pathImpl))) { + return reject('path-overlaps-protected-root'); + } + return deepFreeze({ ok: true, path: indexPath }); +} diff --git a/src/lib/exec.mjs b/src/lib/exec.mjs index 12ca142..4c33ad3 100644 --- a/src/lib/exec.mjs +++ b/src/lib/exec.mjs @@ -1,7 +1,7 @@ // Subprocess helpers. Rule (binding, from the plan): NOTHING goes through a // shell string — execFile with argv arrays only, shell ALWAYS false. // -// npm/npx/claude/ruflo/aqe/claude-flow are .cmd shims on Windows, and +// npm/npx/claude/deja/ruflo/aqe/claude-flow are .cmd shims on Windows, and // Windows' CreateProcess cannot launch a .cmd directly — that historically // forced `shell:true`, which hands Node's own cmd+args JOIN of the whole // command line to cmd.exe as ONE string (CVE-class: any arg with `&`/`|`/`^` @@ -19,7 +19,9 @@ import { isWindows } from './paths.mjs'; const pexecFile = promisify(execFile); const MAX_EXEC_BUFFER = 16 * 1024 * 1024; -const CMD_SHIMS = new Set(['npm', 'npx', 'claude', 'codex', 'opencode', 'ruflo', 'aqe', 'claude-flow']); +const CMD_SHIMS = new Set([ + 'npm', 'npx', 'claude', 'codex', 'opencode', 'deja', 'ruflo', 'aqe', 'claude-flow', +]); /** Build a shell-free invocation for `cmd`, trying Windows' shim extensions in * PATHEXT order. A native executable is launched directly; a .cmd shim is @@ -79,8 +81,9 @@ export function resolveShim(cmd, args = [], { windows = isWindows, env = process export async function run(cmd, args = [], opts = {}) { try { const env = opts.env ? { ...process.env, ...opts.env } : process.env; + const windows = opts.windows ?? isWindows; const invocation = CMD_SHIMS.has(cmd) - ? resolveShim(cmd, args, { env }) + ? resolveShim(cmd, args, { windows, env }) : { command: cmd, args }; if (invocation.resolved === false) { return { code: 1, stdout: '', stderr: `No safe Windows invocation found for ${cmd}` }; diff --git a/tests/kit/deja-vu.test.mjs b/tests/kit/deja-vu.test.mjs new file mode 100644 index 0000000..e0c14e5 --- /dev/null +++ b/tests/kit/deja-vu.test.mjs @@ -0,0 +1,223 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + DEJA_VU_BIN, + DEJA_VU_DOCTOR_SCHEMA_VERSION, + DEJA_VU_MIN_VERSION, + DEJA_VU_PACKAGE, + DEJA_VU_TARGETS, + buildDejaVuInstallCommand, + buildDejaVuUninstallCommand, + parseDejaVuDoctor, + parseDejaVuInstallHelp, + requiredDejaVuTargets, + validateDejaVuIndexPath, +} from '../../src/lib/deja-vu.mjs'; + +const FIXTURES = new URL('./fixtures/deja-vu/', import.meta.url); +const fixture = (name) => fs.readFileSync(new URL(name, FIXTURES), 'utf8'); + +test('v0.19 companion identity and Kit-owned host target map are immutable', () => { + assert.equal(DEJA_VU_PACKAGE, '@vshulcz/deja-vu'); + assert.equal(DEJA_VU_MIN_VERSION, '0.19.0'); + assert.equal(DEJA_VU_BIN, 'deja'); + assert.equal(DEJA_VU_DOCTOR_SCHEMA_VERSION, 2); + assert.deepEqual(DEJA_VU_TARGETS, { + claude: { mcp: 'claude-code', auto: 'claude-auto' }, + codex: { mcp: 'codex', auto: 'codex-auto' }, + opencode: { mcp: 'opencode', auto: 'opencode-auto' }, + }); + assert.equal(Object.isFrozen(DEJA_VU_TARGETS), true); + assert.equal(Object.isFrozen(DEJA_VU_TARGETS.claude), true); + assert.deepEqual(requiredDejaVuTargets(), [ + 'claude-code', 'claude-auto', 'codex', 'codex-auto', 'opencode', 'opencode-auto', + ]); +}); + +test('doctor schema v2 becomes bounded, path-free facts and accepts additive fields', () => { + const raw = JSON.parse(fixture('doctor-v2.json')); + raw.added_in_v2 = { path: '/private/SENTINEL/additive', error: 'SENTINEL additive' }; + raw.index.future_counter = 42; + + const parsed = parseDejaVuDoctor(raw); + assert.equal(parsed.state, 'ok'); + assert.equal(parsed.reason, null); + assert.equal(Object.isFrozen(parsed), true); + assert.deepEqual(parsed.facts, { + schemaVersion: 2, + stores: { + total: 2, + states: { + ok: 1, missing: 0, unreadable: 0, 'parsed-zero': 0, denied: 1, + 'needs-sqlite3': 0, 'needs-zstd': 0, unplugged: 0, unknown: 0, + }, + partial: 1, + unchecked: 0, + }, + index: { state: 'stale', staleStores: 1 }, + mcp: { + targets: { + 'claude-code': 'wired', + 'claude-auto': 'unknown', + codex: 'unknown', + 'codex-auto': 'unknown', + opencode: 'unknown', + 'opencode-auto': 'unknown', + }, + unknownTargets: 1, + }, + sqlite3: { state: 'ok' }, + version: { state: 'offline', current: '0.19.0' }, + policy: { + state: 'unreadable', + indexedSessions: 10, + withheld: { search: 0, mcp: 0, auto: 3 }, + }, + sync: { state: 'unreadable', peerCount: 1, peersWithErrors: 1, peersAhead: 1 }, + }); + const serialized = JSON.stringify(parsed); + assert.doesNotMatch(serialized, /SENTINEL|\/private|\.jsonl|\.claude/); +}); + +test('doctor parser accepts JSON text but degrades missing, future, malformed, and unsafe shapes', () => { + assert.equal(parseDejaVuDoctor(fixture('doctor-v2.json')).state, 'ok'); + + const cases = [ + [{}, 'schema-missing'], + [{ schema_version: 999 }, 'schema-unsupported'], + ['{"schema_version":2,', 'json-malformed'], + [[], 'envelope-invalid'], + [{ ...JSON.parse(fixture('doctor-v2.json')), stores: 'SENTINEL raw shape' }, 'shape-invalid'], + ]; + for (const [input, reason] of cases) { + const parsed = parseDejaVuDoctor(input); + assert.equal(parsed.state, 'degraded'); + assert.equal(parsed.reason, reason); + assert.equal(parsed.facts, null); + assert.doesNotMatch(JSON.stringify(parsed), /SENTINEL|raw shape/); + } +}); + +test('doctor unknown enum values are retained only as controlled unknown facts', () => { + const raw = JSON.parse(fixture('doctor-v2.json')); + raw.index.state = 'SENTINEL-new-index-state'; + raw.stores[0].state = 'SENTINEL-new-store-state'; + raw.mcp[0].state = 'SENTINEL-new-mcp-state'; + + const parsed = parseDejaVuDoctor(raw); + assert.equal(parsed.state, 'degraded'); + assert.equal(parsed.reason, 'value-unsupported'); + assert.equal(parsed.facts.index.state, 'unknown'); + assert.equal(parsed.facts.stores.states.unknown, 1); + assert.equal(parsed.facts.mcp.targets['claude-code'], 'unknown'); + assert.doesNotMatch(JSON.stringify(parsed), /SENTINEL/); +}); + +test('install help capability parser reads only the target block and fails closed', () => { + const healthy = parseDejaVuInstallHelp(fixture('install-help-v0.19.0.txt')); + assert.equal(healthy.supported, true); + assert.deepEqual(healthy.missingTargets, []); + assert.deepEqual(healthy.requiredTargets, requiredDejaVuTargets()); + + const missing = fixture('install-help-v0.19.0.txt') + .replace('claude-code, ', '') + + '\nExample prose outside the target block: claude-code\n'; + const incompatible = parseDejaVuInstallHelp(missing); + assert.equal(incompatible.supported, false); + assert.deepEqual(incompatible.missingTargets, ['claude-code']); + + assert.deepEqual(parseDejaVuInstallHelp('claude-code codex opencode'), { + supported: false, + requiredTargets: requiredDejaVuTargets(), + missingTargets: requiredDejaVuTargets(), + }); +}); + +test('command construction uses one explicit target and suppresses guidance and per-target warmup', () => { + assert.deepEqual(buildDejaVuInstallCommand('claude', 'mcp'), { + command: 'deja', + args: ['install', 'claude-code', '--no-guidance', '--no-index'], + }); + assert.deepEqual(buildDejaVuInstallCommand('codex', 'auto'), { + command: 'deja', + args: ['install', 'codex-auto', '--no-guidance', '--no-index'], + }); + assert.deepEqual(buildDejaVuUninstallCommand('opencode', 'auto'), { + command: 'deja', args: ['uninstall', 'opencode-auto'], + }); + for (const bad of [ + () => buildDejaVuInstallCommand('cursor', 'mcp'), + () => buildDejaVuInstallCommand('claude', '--all'), + () => buildDejaVuUninstallCommand('claude', '--auto'), + ]) assert.throws(bad, /unsupported deja-vu host or mode/); +}); + +test('derived index validation accepts only canonical index.db below an allowed data root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-deja-path-')); + const home = path.join(root, 'home'); + const defaultRoot = path.join(home, '.cache', 'deja'); + const source = path.join(home, '.claude', 'projects'); + const config = path.join(home, '.config', 'deja'); + fs.mkdirSync(defaultRoot, { recursive: true }); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(config, { recursive: true }); + try { + const expected = path.join(defaultRoot, 'index.db'); + const canonicalExpected = path.join(fs.realpathSync.native(defaultRoot), 'index.db'); + assert.deepEqual(validateDejaVuIndexPath(expected, { homeDir: home, sourceRoots: [source], configRoots: [config] }), { + ok: true, path: canonicalExpected, + }); + + const override = path.join(root, 'private-index', 'index.db'); + assert.equal(validateDejaVuIndexPath(override, { homeDir: home }).ok, false); + const acceptedOverride = validateDejaVuIndexPath(override, { + homeDir: home, allowedRoots: [path.dirname(override)], + }); + assert.equal(acceptedOverride.ok, true); + assert.equal(path.basename(acceptedOverride.path), 'index.db'); + + const rejected = [ + home, + '.', + path.join(defaultRoot, '..', '..'), + path.join(source, 'index.db'), + path.join(config, 'index.db'), + path.join(defaultRoot, 'not-the-index'), + ]; + for (const candidate of rejected) { + const result = validateDejaVuIndexPath(candidate, { + homeDir: home, + allowedRoots: [defaultRoot, source, config], + sourceRoots: [source], + configRoots: [config], + }); + assert.equal(result.ok, false, `${candidate} must be rejected`); + assert.equal('path' in result, false, 'a rejection must not echo the sensitive path'); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('derived index validation rejects a symlink escape without returning the raw path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-deja-link-')); + const home = path.join(root, 'home'); + const defaultRoot = path.join(home, '.cache', 'deja'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(defaultRoot, { recursive: true }); + fs.mkdirSync(outside); + try { + const link = path.join(defaultRoot, 'escape'); + fs.symlinkSync(outside, link, process.platform === 'win32' ? 'junction' : 'dir'); + const candidate = path.join(link, 'index.db'); + const result = validateDejaVuIndexPath(candidate, { homeDir: home }); + assert.equal(result.ok, false); + assert.equal('path' in result, false); + assert.doesNotMatch(JSON.stringify(result), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 6c62c75..4a3d003 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -122,6 +122,29 @@ test('resolveShim builds safe native and PowerShell invocations in PATHEXT order } }); +test('run() routes the deja npm binary through safe Windows shim resolution', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-deja-shim-')); + const bin = path.join(root, 'bin'); + const shim = path.join(bin, 'deja.exe'); + const hostile = ['install', 'claude-code', 'hello & whoami', '$(echo pwned)']; + fs.mkdirSync(bin); + try { + fs.writeFileSync( + shim, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));\n`, + { mode: 0o755 }, + ); + const result = await run('deja', hostile, { + windows: true, + env: { PATH: bin, PATHEXT: '.EXE' }, + }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), hostile); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('Windows PowerShell shim execution preserves hostile arguments literally', { skip: process.platform !== 'win32', }, async () => { diff --git a/tests/kit/fixtures/deja-vu/doctor-v2.json b/tests/kit/fixtures/deja-vu/doctor-v2.json new file mode 100644 index 0000000..7e0e2ae --- /dev/null +++ b/tests/kit/fixtures/deja-vu/doctor-v2.json @@ -0,0 +1,55 @@ +{ + "schema_version": 2, + "stores": [ + { + "name": "claude", + "state": "ok", + "paths": ["/private/SENTINEL/transcripts"], + "files": 12, + "indexed_sessions": 9 + }, + { + "name": "codex", + "state": "denied", + "paths": ["/private/SENTINEL/codex"], + "files": 3, + "indexed_sessions": 1, + "denied": "/private/SENTINEL/codex/secret.jsonl", + "partial": true + } + ], + "index": { + "state": "stale", + "path": "/private/SENTINEL/cache/deja/index.db", + "stale_stores": 1 + }, + "mcp": [ + {"name": "claude-code", "state": "wired", "path": "/private/SENTINEL/.claude.json"}, + {"name": "private-SENTINEL-harness", "state": "not-wired", "path": "/private/SENTINEL/other.json"} + ], + "sqlite3": {"state": "ok", "path": "/private/SENTINEL/sqlite3"}, + "version": {"state": "offline", "current": "0.19.0"}, + "policy": { + "state": "unreadable", + "path": "/private/SENTINEL/policy.json", + "error": "SENTINEL policy details", + "indexed_sessions": 10, + "activations": { + "search": {"rule": "allow all SENTINEL", "withheld": 0}, + "mcp": {"rule": "allow all SENTINEL", "withheld": 0}, + "auto": {"rule": "deny SENTINEL", "withheld": 3} + } + }, + "sync": { + "state": "unreadable", + "error": "SENTINEL peer file", + "peers": [ + { + "host": "SENTINEL-private-host", + "sessions_from_there": 4, + "last_error": "SENTINEL ssh failure", + "stamped_ahead": true + } + ] + } +} diff --git a/tests/kit/fixtures/deja-vu/install-help-v0.19.0.txt b/tests/kit/fixtures/deja-vu/install-help-v0.19.0.txt new file mode 100644 index 0000000..eed0ace --- /dev/null +++ b/tests/kit/fixtures/deja-vu/install-help-v0.19.0.txt @@ -0,0 +1,9 @@ +deja install | --all | --auto [--no-guidance] [--no-index] +deja uninstall | --all | --auto + targets: + claude-code, claude-auto, codex, codex-auto, opencode, opencode-auto, + cursor, cursor-auto, gemini, gemini-auto, antigravity, antigravity-auto, + qwen, qwen-auto, kimi, kimi-auto, hermes, hermes-auto, pi, pi-auto, + omp, omp-auto, deepseek, deepseek-auto, openclaw, openclaw-auto, + cline, cline-auto, goose, goose-auto, grok, grok-auto, copilot, roo, + aider, zed, statusline From e79d7bcd5f74e77f7ab29a070477cda6d7305688 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 12:11:37 -0700 Subject: [PATCH 04/19] docs(deja-vu): add managed companion runbook --- README.md | 14 ++- docs/DEJA-VU.md | 222 ++++++++++++++++++++++++++++++++++++++++ docs/INSTALLATION.md | 18 +++- docs/SETUP.md | 26 +++++ docs/TROUBLESHOOTING.md | 6 ++ docs/UPGRADING.md | 30 +++++- package.json | 1 + 7 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 docs/DEJA-VU.md diff --git a/README.md b/README.md index 87aa19c..c495192 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ npm install -g @pacphi/agentic-kit@next # alpha channel until 4.0.0 GA ak setup # once per machine; run inside a git repo to set that project up too ak setup --codex # …or bring up Claude + Codex together in one shot ak setup --opencode # …and wire ruflo + ruvnet-brain into opencode (third host) +ak setup --with-deja-vu # optional local transcript search, MCP mode by default ``` > [!IMPORTANT] @@ -36,6 +37,7 @@ container for you. See [docs/DEVCONTAINERS.md](docs/DEVCONTAINERS.md). - **One command** installs + heals + *proves* ruflo & agentic-qe — native SQLite, memory, security, statusline (past npm's `allow-scripts` gate). - **Source-grounded knowledge:** *RuvNet Brain* — an offline knowledge base over the rUv stack — powers the `search_ruvnet` MCP tool, so answers about ruflo/AgentDB/RVF/SPARC cite real source instead of stale training priors. +- **Local transcript recall (optional):** [deja-vu](docs/DEJA-VU.md) indexes coding-agent histories for MCP search or host-native automatic recall. It is off by default because the derived plaintext index has its own privacy and retention boundary. - **Multi-host execution (optional):** Claude, Codex, and opt-in OpenCode can share one activity policy; `ak run` is the canonical executor, while `ak setup --codex` enables the subscription-backed Claude/Codex defaults. - **Self-healing:** `ak sync` re-converges after every upgrade; `ak status` and a local dashboard show what's *actually* on — never assumed. - **Honest by construction:** every guard traces to a filed upstream issue, and `ak x verify` proves the paths end-to-end against real CLIs. @@ -79,7 +81,9 @@ in [docs/archive/](docs/archive/). ```text ak status + one suggested next action ak setup first-time setup — machine and/or the project you're standing in - [--codex] [--opencode] [--primary-host claude|codex] [--project] [--minimal] [--yes] [--no-aqe] [--no-security] [--reconfigure] + [--codex] [--opencode] [--primary-host claude|codex] [--with-deja-vu] + [--deja-vu-mode mcp|auto] [--no-deja-vu] [--project] [--minimal] + [--yes] [--no-aqe] [--no-security] [--reconfigure] ak status read-only dashboard: what's true, what's drifted [--json] [--deep] ak sync converge to good: upgrade + heal + verify [--dry-run] [--no-upgrade] ak dashboard open the local web dashboard (auto-opens your browser) @@ -97,13 +101,19 @@ ak host manage execution hosts, routing, and provider bindings status | pick | refresh | off ak run execute a host-neutral activity pipeline (including explicit OpenCode routes)