diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6f3bfe0..73511ca 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Verified Solution Cards retrieved from an agent-authored conceptual problem statement.", - "version": "0.4.0" + "version": "0.4.1" }, "plugins": [ { "name": "blaze", "source": "./plugins/claude-code", "description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.", - "version": "0.4.0", + "version": "0.4.1", "keywords": [ "memory", "retrieval", diff --git a/install.md b/install.md index 26be640..69ff39a 100644 --- a/install.md +++ b/install.md @@ -1,4 +1,4 @@ -# Install Blaze 0.4.0 +# Install Blaze 0.4.1 Blaze shares verified coding lessons through an explicitly invoked client. Installing it does not authorize uploading prompts, source or transcripts. diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 86a54f6..d32e6c1 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "blaze", "displayName": "Blaze", - "version": "0.4.0", + "version": "0.4.1", "description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.", "author": { "name": "Blaze" diff --git a/plugins/claude-code/blaze-client.mjs b/plugins/claude-code/blaze-client.mjs index 778d325..e601581 100644 --- a/plugins/claude-code/blaze-client.mjs +++ b/plugins/claude-code/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.0"; +export const CLIENT_VERSION = "0.4.1"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -644,6 +644,15 @@ async function locked(path, work) { /** Explicit lifecycle operations. Hooks never call this function or fetch a release. */ export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); + const invokedRoot = resolve(dirname(helperPath)); + // Hosts can discover another host's global copy. Only an already recorded + // direct bundle (or its interrupted transaction) can establish ownership. + // Credentials and receipts still belong to the invoking tool's state directory. + if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { + const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); + homePath(home,invokedRoot);homePath(home,recordedState); + if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json"); diff --git a/plugins/claude-code/skills/blaze/SKILL.md b/plugins/claude-code/skills/blaze/SKILL.md index 1ce7ad8..e420d05 100644 --- a/plugins/claude-code/skills/blaze/SKILL.md +++ b/plugins/claude-code/skills/blaze/SKILL.md @@ -3,7 +3,7 @@ name: blaze description: Reuse and improve verified coding lessons across agents. Use for a nontrivial debugging or implementation problem where an earlier solution could help, when a Blaze offer or receipt appears, or when the user asks to install, update, contribute to, or link Blaze. Check applicability, verify locally, and close the lookup with an honest outcome and contribution disposition. compatibility: Requires Node.js 20 or later and explicit HTTPS access to the configured Blaze service. Local reminder hooks need no network access. No model provider credentials are needed. metadata: - version: "0.4.0" + version: "0.4.1" --- # Blaze @@ -30,6 +30,11 @@ means that file. `` is the **agent host**, independent of its model provid Check the active host's discovery rules. A profile may disable a shared root; do not change its trust settings or other agents' configuration. +Some hosts discover more than one global copy. Lifecycle commands follow the +recorded direct bundle beside this helper, including that bundle's shared pin +and update lock. Credentials and receipts remain separate for each host. An +unrecorded or manager-owned copy still uses its owning manager. + ## Start and stay current When first using Blaze in a conversation, run `node status --tool `. diff --git a/plugins/claude-code/skills/blaze/blaze-client.mjs b/plugins/claude-code/skills/blaze/blaze-client.mjs index 778d325..e601581 100644 --- a/plugins/claude-code/skills/blaze/blaze-client.mjs +++ b/plugins/claude-code/skills/blaze/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.0"; +export const CLIENT_VERSION = "0.4.1"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -644,6 +644,15 @@ async function locked(path, work) { /** Explicit lifecycle operations. Hooks never call this function or fetch a release. */ export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); + const invokedRoot = resolve(dirname(helperPath)); + // Hosts can discover another host's global copy. Only an already recorded + // direct bundle (or its interrupted transaction) can establish ownership. + // Credentials and receipts still belong to the invoking tool's state directory. + if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { + const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); + homePath(home,invokedRoot);homePath(home,recordedState); + if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json"); diff --git a/plugins/client/blaze-client.mjs b/plugins/client/blaze-client.mjs index 778d325..e601581 100644 --- a/plugins/client/blaze-client.mjs +++ b/plugins/client/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.0"; +export const CLIENT_VERSION = "0.4.1"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -644,6 +644,15 @@ async function locked(path, work) { /** Explicit lifecycle operations. Hooks never call this function or fetch a release. */ export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); + const invokedRoot = resolve(dirname(helperPath)); + // Hosts can discover another host's global copy. Only an already recorded + // direct bundle (or its interrupted transaction) can establish ownership. + // Credentials and receipts still belong to the invoking tool's state directory. + if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { + const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); + homePath(home,invokedRoot);homePath(home,recordedState); + if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json"); diff --git a/plugins/client/lifecycle.test.mjs b/plugins/client/lifecycle.test.mjs index 1b72eb4..ed22a74 100644 --- a/plugins/client/lifecycle.test.mjs +++ b/plugins/client/lifecycle.test.mjs @@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { createClient, createLifecycle, compareVersions, toolPaths, validateRelease } from "./blaze-client.mjs"; +import { CLIENT_VERSION, createClient, createLifecycle, compareVersions, toolPaths, validateRelease } from "./blaze-client.mjs"; const hash = value => createHash("sha256").update(value).digest("hex"); const source = readFileSync(new URL("./blaze-client.mjs",import.meta.url)); @@ -91,7 +91,7 @@ test("update, pin, rollback and uninstall preserve receipts and one credential", await lifecycle.pin("0.4.0");control.release=bundle("0.5.0"); assert.equal((await lifecycle.checkUpdate()).update,"pinned");await assert.rejects(lifecycle.update(),/pinned/); await lifecycle.pin(null);assert.equal((await lifecycle.update()).version,"0.5.0"); - assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(lifecycle.status().running_version,"0.4.0"); + assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(lifecycle.status().running_version,CLIENT_VERSION); assert.equal((await lifecycle.rollback()).version,"0.4.0");assert.equal(lifecycle.status().pin,"0.4.0"); assert.deepEqual(get(join(paths.state,"receipts",`${id}.json`)),receipt);assert.equal(readFileSync(paths.token,"utf8"),secret); assert.equal((await lifecycle.uninstall()).installation,"removed");assert.equal(existsSync(paths.root),false); @@ -159,6 +159,40 @@ test("one bundle lock spans tools that share a discovery directory",async t=>{ await assert.rejects(createLifecycle({...options,tool:"cursor"}).update(),/Another Blaze operation/);assert.equal(requests.length,count); }); +test("a host discovering another recorded global bundle uses that bundle's pin and lock while retaining its own identity",async t=>{ + const {lifecycle,state,options,paths,control,requests}=await fixture(t);await lifecycle.install(); + const otherPaths=toolPaths("opencode",options.home); + const dedicated=createLifecycle({...options,tool:"opencode",helperPath:join(otherPaths.root,"blaze-client.mjs")}); + await dedicated.install(); + const credentials=[paths.token,otherPaths.token].map(path=>readFileSync(path,"utf8")); + const receiptId=randomUUID(),receipt={decision_id:receiptId};put(join(otherPaths.state,"receipts",`${receiptId}.json`),receipt); + const discovered=createLifecycle({...options,tool:"opencode"}); + assert.equal(discovered.status().installation,"direct"); + await discovered.pin("0.4.0");assert.equal(lifecycle.status().pin,"0.4.0");assert.equal(dedicated.status().pin,null); + control.release=bundle("0.5.0");await assert.rejects(discovered.update(),/pinned/);await discovered.pin(null); + put(join(state,"update.lock"),{pid:process.pid,nonce:randomUUID()});const before=requests.length; + await assert.rejects(discovered.update(),/Another Blaze operation/);assert.equal(requests.length,before);rmSync(join(state,"update.lock")); + assert.equal((await discovered.update()).version,"0.5.0"); + assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(dedicated.status().disk_version,"0.4.0"); + assert.deepEqual([paths.token,otherPaths.token].map(path=>readFileSync(path,"utf8")),credentials); + assert.notEqual(get(paths.token).token,get(otherPaths.token).token); + assert.deepEqual(get(join(otherPaths.state,"receipts",`${receiptId}.json`)),receipt); +}); + +test("an unrecorded global copy cannot update a host's different recorded installation",async t=>{ + const {options,requests}=await fixture(t),paths=toolPaths("opencode",options.home); + await createLifecycle({...options,tool:"opencode",helperPath:join(paths.root,"blaze-client.mjs")}).install(); + const before=requests.length,unrecorded=createLifecycle({...options,tool:"opencode"}); + assert.equal((await unrecorded.update()).installation,"managed_or_unrecorded");assert.equal(requests.length,before); +}); + +test("cross-host discovery preserves origin and symlink checks on recorded ownership",async t=>{ + const {lifecycle,options,state,requests}=await fixture(t);await lifecycle.install();const before=requests.length; + assert.throws(()=>createLifecycle({...options,tool:"opencode",origin:"https://example.invalid"}).status(),/provenance/); + const metadata=join(state,"installation.json"),saved=join(state,"saved.json");renameSync(metadata,saved);symlinkSync(saved,metadata); + assert.throws(()=>createLifecycle({...options,tool:"opencode"}).status(),/symbolic links/);assert.equal(requests.length,before); +}); + test("intentional requests cache fixed version hints; retired contracts outrank pins",async t=>{ const {lifecycle,paths,options}=await fixture(t);await lifecycle.install();await lifecycle.pin("0.4.0"); let count=0;const client=createClient({origin:options.origin,token:get(paths.token).token,stateDir:join(paths.state,"receipts"),freshnessPath:join(paths.state,"freshness.json"),tool:"codex", diff --git a/release.json b/release.json index 61f35ae..2c825e8 100644 --- a/release.json +++ b/release.json @@ -1,7 +1,7 @@ { - "version": "0.4.0", - "created_at": "2026-09-07T00:00:00.000Z", - "updated_at": "2026-09-07T00:00:00.000Z", + "version": "0.4.1", + "created_at": "2026-09-07T08:58:37.955Z", + "updated_at": "2026-09-07T08:58:37.955Z", "client_contract": 1, "minimum_client_contract": 0 } diff --git a/skill.md b/skill.md index 1ce7ad8..e420d05 100644 --- a/skill.md +++ b/skill.md @@ -3,7 +3,7 @@ name: blaze description: Reuse and improve verified coding lessons across agents. Use for a nontrivial debugging or implementation problem where an earlier solution could help, when a Blaze offer or receipt appears, or when the user asks to install, update, contribute to, or link Blaze. Check applicability, verify locally, and close the lookup with an honest outcome and contribution disposition. compatibility: Requires Node.js 20 or later and explicit HTTPS access to the configured Blaze service. Local reminder hooks need no network access. No model provider credentials are needed. metadata: - version: "0.4.0" + version: "0.4.1" --- # Blaze @@ -30,6 +30,11 @@ means that file. `` is the **agent host**, independent of its model provid Check the active host's discovery rules. A profile may disable a shared root; do not change its trust settings or other agents' configuration. +Some hosts discover more than one global copy. Lifecycle commands follow the +recorded direct bundle beside this helper, including that bundle's shared pin +and update lock. Credentials and receipts remain separate for each host. An +unrecorded or manager-owned copy still uses its owning manager. + ## Start and stay current When first using Blaze in a conversation, run `node status --tool `.