Skip to content

Commit 159fc93

Browse files
authored
Merge pull request #6 from pascalorg/fix/shared-host-lifecycle
Fix lifecycle ownership for shared global discovery
2 parents 275883e + 87c399c commit 159fc93

10 files changed

Lines changed: 85 additions & 14 deletions

File tree

‎.claude-plugin/marketplace.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
},
88
"metadata": {
99
"description": "Verified Solution Cards retrieved from an agent-authored conceptual problem statement.",
10-
"version": "0.4.0"
10+
"version": "0.4.1"
1111
},
1212
"plugins": [
1313
{
1414
"name": "blaze",
1515
"source": "./plugins/claude-code",
1616
"description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.",
17-
"version": "0.4.0",
17+
"version": "0.4.1",
1818
"keywords": [
1919
"memory",
2020
"retrieval",

‎install.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Install Blaze 0.4.0
1+
# Install Blaze 0.4.1
22

33
Blaze shares verified coding lessons through an explicitly invoked client.
44
Installing it does not authorize uploading prompts, source or transcripts.

‎plugins/claude-code/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33
"name": "blaze",
44
"displayName": "Blaze",
5-
"version": "0.4.0",
5+
"version": "0.4.1",
66
"description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.",
77
"author": {
88
"name": "Blaze"

‎plugins/claude-code/blaze-client.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.0";
14+
export const CLIENT_VERSION = "0.4.1";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -644,6 +644,15 @@ async function locked(path, work) {
644644
/** Explicit lifecycle operations. Hooks never call this function or fetch a release. */
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647+
const invokedRoot = resolve(dirname(helperPath));
648+
// Hosts can discover another host's global copy. Only an already recorded
649+
// direct bundle (or its interrupted transaction) can establish ownership.
650+
// Credentials and receipts still belong to the invoking tool's state directory.
651+
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652+
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653+
homePath(home,invokedRoot);homePath(home,recordedState);
654+
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
}
647656
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
648657
const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32));
649658
const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json");

‎plugins/claude-code/skills/blaze/SKILL.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: blaze
33
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.
44
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.
55
metadata:
6-
version: "0.4.0"
6+
version: "0.4.1"
77
---
88

99
# Blaze
@@ -30,6 +30,11 @@ means that file. `<tool>` is the **agent host**, independent of its model provid
3030
Check the active host's discovery rules. A profile may disable a shared root;
3131
do not change its trust settings or other agents' configuration.
3232

33+
Some hosts discover more than one global copy. Lifecycle commands follow the
34+
recorded direct bundle beside this helper, including that bundle's shared pin
35+
and update lock. Credentials and receipts remain separate for each host. An
36+
unrecorded or manager-owned copy still uses its owning manager.
37+
3338
## Start and stay current
3439

3540
When first using Blaze in a conversation, run `node <client> status --tool <tool>`.

‎plugins/claude-code/skills/blaze/blaze-client.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.0";
14+
export const CLIENT_VERSION = "0.4.1";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -644,6 +644,15 @@ async function locked(path, work) {
644644
/** Explicit lifecycle operations. Hooks never call this function or fetch a release. */
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647+
const invokedRoot = resolve(dirname(helperPath));
648+
// Hosts can discover another host's global copy. Only an already recorded
649+
// direct bundle (or its interrupted transaction) can establish ownership.
650+
// Credentials and receipts still belong to the invoking tool's state directory.
651+
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652+
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653+
homePath(home,invokedRoot);homePath(home,recordedState);
654+
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
}
647656
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
648657
const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32));
649658
const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json");

‎plugins/client/blaze-client.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.0";
14+
export const CLIENT_VERSION = "0.4.1";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -644,6 +644,15 @@ async function locked(path, work) {
644644
/** Explicit lifecycle operations. Hooks never call this function or fetch a release. */
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647+
const invokedRoot = resolve(dirname(helperPath));
648+
// Hosts can discover another host's global copy. Only an already recorded
649+
// direct bundle (or its interrupted transaction) can establish ownership.
650+
// Credentials and receipts still belong to the invoking tool's state directory.
651+
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652+
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653+
homePath(home,invokedRoot);homePath(home,recordedState);
654+
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
}
647656
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
648657
const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32));
649658
const metadataPath = join(bundleState, "installation.json"), journalPath = join(bundleState, "transaction.json");

‎plugins/client/lifecycle.test.mjs‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
55
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
66
import { tmpdir } from "node:os";
77
import { join, resolve } from "node:path";
8-
import { createClient, createLifecycle, compareVersions, toolPaths, validateRelease } from "./blaze-client.mjs";
8+
import { CLIENT_VERSION, createClient, createLifecycle, compareVersions, toolPaths, validateRelease } from "./blaze-client.mjs";
99

1010
const hash = value => createHash("sha256").update(value).digest("hex");
1111
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",
9191
await lifecycle.pin("0.4.0");control.release=bundle("0.5.0");
9292
assert.equal((await lifecycle.checkUpdate()).update,"pinned");await assert.rejects(lifecycle.update(),/pinned/);
9393
await lifecycle.pin(null);assert.equal((await lifecycle.update()).version,"0.5.0");
94-
assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(lifecycle.status().running_version,"0.4.0");
94+
assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(lifecycle.status().running_version,CLIENT_VERSION);
9595
assert.equal((await lifecycle.rollback()).version,"0.4.0");assert.equal(lifecycle.status().pin,"0.4.0");
9696
assert.deepEqual(get(join(paths.state,"receipts",`${id}.json`)),receipt);assert.equal(readFileSync(paths.token,"utf8"),secret);
9797
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=>{
159159
await assert.rejects(createLifecycle({...options,tool:"cursor"}).update(),/Another Blaze operation/);assert.equal(requests.length,count);
160160
});
161161

162+
test("a host discovering another recorded global bundle uses that bundle's pin and lock while retaining its own identity",async t=>{
163+
const {lifecycle,state,options,paths,control,requests}=await fixture(t);await lifecycle.install();
164+
const otherPaths=toolPaths("opencode",options.home);
165+
const dedicated=createLifecycle({...options,tool:"opencode",helperPath:join(otherPaths.root,"blaze-client.mjs")});
166+
await dedicated.install();
167+
const credentials=[paths.token,otherPaths.token].map(path=>readFileSync(path,"utf8"));
168+
const receiptId=randomUUID(),receipt={decision_id:receiptId};put(join(otherPaths.state,"receipts",`${receiptId}.json`),receipt);
169+
const discovered=createLifecycle({...options,tool:"opencode"});
170+
assert.equal(discovered.status().installation,"direct");
171+
await discovered.pin("0.4.0");assert.equal(lifecycle.status().pin,"0.4.0");assert.equal(dedicated.status().pin,null);
172+
control.release=bundle("0.5.0");await assert.rejects(discovered.update(),/pinned/);await discovered.pin(null);
173+
put(join(state,"update.lock"),{pid:process.pid,nonce:randomUUID()});const before=requests.length;
174+
await assert.rejects(discovered.update(),/Another Blaze operation/);assert.equal(requests.length,before);rmSync(join(state,"update.lock"));
175+
assert.equal((await discovered.update()).version,"0.5.0");
176+
assert.equal(lifecycle.status().disk_version,"0.5.0");assert.equal(dedicated.status().disk_version,"0.4.0");
177+
assert.deepEqual([paths.token,otherPaths.token].map(path=>readFileSync(path,"utf8")),credentials);
178+
assert.notEqual(get(paths.token).token,get(otherPaths.token).token);
179+
assert.deepEqual(get(join(otherPaths.state,"receipts",`${receiptId}.json`)),receipt);
180+
});
181+
182+
test("an unrecorded global copy cannot update a host's different recorded installation",async t=>{
183+
const {options,requests}=await fixture(t),paths=toolPaths("opencode",options.home);
184+
await createLifecycle({...options,tool:"opencode",helperPath:join(paths.root,"blaze-client.mjs")}).install();
185+
const before=requests.length,unrecorded=createLifecycle({...options,tool:"opencode"});
186+
assert.equal((await unrecorded.update()).installation,"managed_or_unrecorded");assert.equal(requests.length,before);
187+
});
188+
189+
test("cross-host discovery preserves origin and symlink checks on recorded ownership",async t=>{
190+
const {lifecycle,options,state,requests}=await fixture(t);await lifecycle.install();const before=requests.length;
191+
assert.throws(()=>createLifecycle({...options,tool:"opencode",origin:"https://example.invalid"}).status(),/provenance/);
192+
const metadata=join(state,"installation.json"),saved=join(state,"saved.json");renameSync(metadata,saved);symlinkSync(saved,metadata);
193+
assert.throws(()=>createLifecycle({...options,tool:"opencode"}).status(),/symbolic links/);assert.equal(requests.length,before);
194+
});
195+
162196
test("intentional requests cache fixed version hints; retired contracts outrank pins",async t=>{
163197
const {lifecycle,paths,options}=await fixture(t);await lifecycle.install();await lifecycle.pin("0.4.0");
164198
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",

‎release.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"version": "0.4.0",
3-
"created_at": "2026-09-07T00:00:00.000Z",
4-
"updated_at": "2026-09-07T00:00:00.000Z",
2+
"version": "0.4.1",
3+
"created_at": "2026-09-07T08:58:37.955Z",
4+
"updated_at": "2026-09-07T08:58:37.955Z",
55
"client_contract": 1,
66
"minimum_client_contract": 0
77
}

‎skill.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: blaze
33
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.
44
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.
55
metadata:
6-
version: "0.4.0"
6+
version: "0.4.1"
77
---
88

99
# Blaze
@@ -30,6 +30,11 @@ means that file. `<tool>` is the **agent host**, independent of its model provid
3030
Check the active host's discovery rules. A profile may disable a shared root;
3131
do not change its trust settings or other agents' configuration.
3232

33+
Some hosts discover more than one global copy. Lifecycle commands follow the
34+
recorded direct bundle beside this helper, including that bundle's shared pin
35+
and update lock. Credentials and receipts remain separate for each host. An
36+
unrecorded or manager-owned copy still uses its owning manager.
37+
3338
## Start and stay current
3439

3540
When first using Blaze in a conversation, run `node <client> status --tool <tool>`.

0 commit comments

Comments
 (0)