@passioncode-ai/passioncode 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16 - 2026-10-01
4
+
5
+ ### Fixed
6
+
7
+ - The packed lifecycle release check accepts both npm 11’s array and npm 12’s
8
+ package-name-keyed JSON output, and rejects ambiguous or malformed pack results.
9
+ - The update probe accepts npm 12’s singleton metadata array while preserving
10
+ publisher checks and rejecting ambiguous or malformed responses.
11
+ - The 0.1.15 tag and GitHub release remain historical: its release workflow stopped
12
+ at this check before npm publication. Version 0.1.16 includes the fixes and shared
13
+ backlog instructions described below.
14
+
15
+ ## 0.1.15 - 2026-10-01
16
+
17
+ ### Fixed
18
+
19
+ - Restore and uninstall preserve recoverable state, reject unsupported options before changes,
20
+ and report failed filesystem or plugin operations truthfully. Updates reject inconsistent
21
+ payloads and stop dependent work after a failed release or marketplace step.
22
+ - Status reports the installed release correctly and retains concurrent probe/configuration
23
+ updates. Self-update checks reject untrusted or incomplete npm publisher metadata and
24
+ recover from failed spawns and future timestamps.
25
+ - Node 18 test discovery, release-tag validation and the packed lifecycle smoke now guard
26
+ the release path. The launcher audit records the regression evidence.
27
+
28
+ ### Changed
29
+
30
+ - The repository skill and SessionStart reminder direct agents to canonical local backlog
31
+ sources and the derived workspace backlog, with one owner per task and source-commit checks.
32
+ - Developer documentation now states the CLI, recovery and self-update contracts and checks
33
+ their local references. Tagged Fabric Agent Adapter 0.5.5 and Observatory Log 0.13.0 stay
34
+ in the family; this release does not add a new family member.
35
+
36
+ ## 0.1.14 - 2026-10-01
37
+
38
+ ### Changed
39
+
40
+ - Fabric Agent Adapter 0.5.5 in the set (`family.json` pins `v0.5.5`, was `v0.5.4`): its shipped
41
+ docs no longer call the public Fabric Agent Contract private; no behaviour changes.
42
+ - Observatory Log 0.13.0 in the set (`family.json` pins Project Observatory `v0.10.0`, was
43
+ `v0.8.2`, which shipped plugin 0.12.3): the plugin's licence metadata is `AGPL-3.0-only OR
44
+ LicenseRef-PassionCode-Commercial`, and `handling-secrets` documents the new Cloudflare presets.
45
+ - The README, `--help`, `family.json` and `package.json` descriptions say what is true: the
46
+ launcher installs the set listed in `family.json`, not every PassionCode.ai skill and plugin.
47
+
3
48
  ## 0.1.13 - 2026-10-01
4
49
 
5
50
  ### Changed
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # PassionCode.ai launcher
2
2
 
3
- The **PassionCode.ai launcher** (`@passioncode-ai/passioncode` on npm) installs and updates every
4
- PassionCode.ai agent skill and plugin — the Fabric Agent Adapter, Observatory Log and the
5
- organization's working rules — for Claude Code and every other agent on your machine, as one set.
3
+ The **PassionCode.ai launcher** (`@passioncode-ai/passioncode` on npm) installs and updates the
4
+ PassionCode.ai skills and plugins listed in [`family.json`](family.json) — the Fabric Agent Adapter,
5
+ Observatory Log and the organization's working rules — for Claude Code and every other agent on your machine, as one set.
6
6
  It is how a teammate's agent gets the tools that build for Fabric, PassionCode.ai's CEO AI agent,
7
7
  and it works on its own: no Fabric install, no account, no key.
8
8
 
@@ -24,8 +24,8 @@ out of it, even when they implement Fabric protocols.
24
24
  1. **Install** (Node 18 or newer; the Claude Code CLI on `PATH` for the plugins):
25
25
  `npx @passioncode-ai/passioncode@latest update`, then restart your agents. Check it with
26
26
  `npx @passioncode-ai/passioncode@latest status` (every member `claude: plugin · hub: n/n`) and
27
- `claude plugin list` (three plugins `@passioncode`). Always call the scoped package: there is
28
- no unscoped `passioncode` package on npm.
27
+ `claude plugin list` (the plugins listed in `family.json`, under `@passioncode`).
28
+ Always call the scoped package shown above.
29
29
  2. **Configure:** nothing to set, no account and no key. Background updates are on by default;
30
30
  `npx @passioncode-ai/passioncode@latest config set update.auto off` turns them off.
31
31
  3. **MCP:** none today, and not planned. The launcher installs skills and session hooks and is
@@ -50,26 +50,41 @@ out of it, even when they implement Fabric protocols.
50
50
  - **Every other agent** (Cursor, Codex, Gemini, OpenCode, Kiro, Windsurf, Goose, …) gets
51
51
  the same skills through `~/.agents/skills`, linked from each channel that exists on
52
52
  the machine.
53
- - Nothing is written to `~/.claude/skills`: a plain copy there shadows the plugin. Such
53
+ - Updates create no skill copies in `~/.claude/skills`: a plain copy there shadows the plugin. Such
54
54
  copies, and hub entries that pointed at a repository's working tree, are moved into
55
55
  `~/.passioncode/quarantine/` — `npx @passioncode-ai/passioncode@latest restore` puts them back.
56
56
  - Each version is an immutable release under `~/.passioncode/releases/<version>`.
57
57
 
58
58
  ## Staying current
59
59
 
60
- At every Claude Code session start the `passioncode` plugin checks npm at most once a
61
- day and, when a newer set is out **and every npm account that publishes it is in the
60
+ At Claude Code session start the `passioncode` plugin uses a cached npm check for up
61
+ to 24 hours and, when a newer set is out **and every npm account that publishes it is in the
62
62
  plugin's `trust.json`**, updates to that exact version in the background — it takes
63
63
  effect in the next session. A version published by anyone else is named and not
64
64
  installed ([SECURITY.md](SECURITY.md#the-self-update-and-who-it-trusts)).
65
65
  `npx @passioncode-ai/passioncode@latest config set update.auto off` makes it only say so.
66
66
 
67
+ `status` reads local state without contacting npm. Its npm version is labelled
68
+ `npm latest (cached)` with the saved check timestamp, or `check time unknown`.
69
+ When that stable version is below the installed stable version, the line also says
70
+ `stale: older than installed`. A newer or equal cached version is still a saved
71
+ observation, not a live registry check. With no saved version it says
72
+ `npm latest: not checked`; `--json` retains the raw `latest` and `checkedAt` fields.
73
+
67
74
  ```bash
68
75
  npx @passioncode-ai/passioncode@latest status # the installed release: its members, their channels, shadows
69
76
  npx @passioncode-ai/passioncode@latest update --dry-run
70
77
  npx @passioncode-ai/passioncode@latest uninstall
71
78
  ```
72
79
 
80
+ For supported flags, JSON fields, exit codes and partial-failure recovery, read the
81
+ [CLI contract](docs/reference/cli.md). `restore` repairs the latest quarantine batch;
82
+ it is not a release rollback. A successful `uninstall` preserves recovery files and
83
+ turns automatic updates off. Unsupported flags are rejected before mutation.
84
+
85
+ Repository docs describe the current source. See the [documentation index](docs/README.md)
86
+ and dated handoffs for which changes have actually been published or installed.
87
+
73
88
  ## Release (maintainers)
74
89
 
75
90
  1. Tag every member at the version to ship; set `ref` to that tag in `family.json`.
@@ -81,7 +96,8 @@ npx @passioncode-ai/passioncode@latest uninstall
81
96
  4. Merge, then tag the merge commit `vX.Y.Z` and push the tag. `.github/workflows/release.yml`
82
97
  validates, checks the tag against both manifests, creates the GitHub release from the
83
98
  CHANGELOG section, vendors every member **from its own repository** at its pinned tag,
84
- runs the tests, installs the packed tarball from a clean `HOME`, publishes
99
+ runs the tests, exercises packed update/status/uninstall with a fake Claude CLI
100
+ in a temporary home (`node scripts/smoke-pack.mjs`), publishes
85
101
  `@passioncode-ai/passioncode` and waits until npm serves it. It runs when the repository
86
102
  variables `RELEASE_ENABLED` and `PUBLISH_NPMJS` are `true`; npm auth is trusted publishing
87
103
  (OIDC) or the `NPM_TOKEN` secret; a private member needs `MEMBERS_READ_TOKEN`.
package/SECURITY.md CHANGED
@@ -11,12 +11,18 @@ that replaces it is verified. For a member's old single-member marketplace
11
11
  (`legacyMarketplaces` in `family.json`), once that plugin is verified and nothing else
12
12
  installed comes from it, it also moves the `extraKnownMarketplaces` entry out of
13
13
  `~/.claude/settings.json` (a copy of the file is kept in the quarantine) and runs
14
- `claude plugin marketplace remove <name>`. It never deletes: every move is listed in
15
- `~/.passioncode/quarantine/<run>/moved.json` and undone by `passioncode restore`.
14
+ `claude plugin marketplace remove <name>`. Displaced files are moved rather than
15
+ deleted: a recovery record is written before the move in
16
+ `~/.passioncode/quarantine/<run>/moved.json`. `restore` processes the latest batch,
17
+ checkpoints completed entries and refuses to overwrite a conflicting user replacement.
18
+ `uninstall` removes owned links/plugins, clears the active release and disables
19
+ automatic updates; it retains release copies and recovery records. A failed removal
20
+ returns a nonzero exit code. See the [CLI recovery contract](docs/reference/cli.md).
16
21
 
17
22
  ## The self-update and who it trusts
18
23
 
19
- The SessionStart hook reads `~/.passioncode/state.json` and, at most once a day, starts
24
+ The SessionStart hook reads `~/.passioncode/state.json` and reuses a check for up to
25
+ 24 hours. An absent, invalid, future or expired timestamp starts
20
26
  `npm view @passioncode-ai/passioncode version maintainers _npmUser --json`, detached, logging to
21
27
  `~/.passioncode/logs/`. It records the latest version, the package's npm maintainers
22
28
  and the account that published that version.
@@ -24,7 +30,8 @@ and the account that published that version.
24
30
  The hook installs a newer version on its own only when all of these hold:
25
31
 
26
32
  - `update.auto` is on (the default);
27
- - the recorded maintainers are not empty, and every maintainer **and** the publishing
33
+ - the npm probe exited successfully, the recorded maintainers are non-empty, the
34
+ publisher is known, and every maintainer **and** the publishing
28
35
  account is listed in `trust.json` inside the plugin (`npmPublishers`);
29
36
  a release published by this repository's `release.yml` through npm trusted publishing
30
37
  appears as `github-actions-oidc` (npm's `npm-oidc-no-reply@github.com` identity, matched
@@ -37,10 +44,19 @@ nothing: a version from an account outside the list is reported in one line nami
37
44
  account ("… not a trusted PassionCode publisher — not installing"); a name nobody has
38
45
  published (npm E404) is silent; a failed check is shown once.
39
46
 
40
- `trust.json` ships **empty**, so auto-update stays inert until the maintainers name the
41
- npm account(s) that publish PassionCode. Adding an account there is a release decision:
47
+ The current allowlist lives in [`plugin/passioncode/trust.json`](plugin/passioncode/trust.json).
48
+ A missing or empty allowlist cannot authorize an update. Adding an account there is a release decision:
42
49
  it is reviewed like code and ships in the package. A person who wants no background
43
50
  updates at all runs `npx @passioncode-ai/passioncode@latest config set update.auto off`.
44
51
 
45
- The published package carries skill text only; the vendor step refuses to build when it
46
- finds a credential-shaped string, or when the self plugin has no valid `trust.json`.
52
+ The package contains executable launcher code and member plugin hooks as well as
53
+ skill text. The vendor scans supported text-file sizes for credential-shaped strings
54
+ and rejects an invalid self-plugin trust list; that scan is not a proof that all
55
+ possible secrets or malicious code are absent. Review member code at its pinned tag.
56
+ Automatic updates trust the npm registry's attribution and the local cached state;
57
+ they do not independently verify signatures or sandbox the installed code.
58
+
59
+ Evidence: `update()`, `restore()` and `uninstall()` in `lib/launcher.js`;
60
+ `parseView()` and `decide()` in `plugin/passioncode/hooks/update-check.js`;
61
+ `scanForSecrets()` in `scripts/vendor.mjs`; regression tests in
62
+ `test/launcher.test.js` and `test/self-update.test.js`.
@@ -2,7 +2,22 @@
2
2
  'use strict';
3
3
  const L = require('../lib/launcher');
4
4
 
5
- const HELP = `passioncode — every PassionCode.ai skill, for every agent on this machine
5
+ function cachedNpmStatus({ latest, checkedAt, installed }) {
6
+ if (!latest) return 'npm latest: not checked';
7
+ const checked = typeof checkedAt === 'string' && Number.isFinite(Date.parse(checkedAt))
8
+ ? `checked ${checkedAt}` : 'check time unknown';
9
+ let stale = false;
10
+ // The registry probe accepts stable x.y.z versions only. Do not guess an
11
+ // ordering for other values that may have been written to the state file.
12
+ if (/^\d+\.\d+\.\d+$/.test(latest) && /^\d+\.\d+\.\d+$/.test(installed)) {
13
+ const a = latest.split('.').map(Number), b = installed.split('.').map(Number);
14
+ const difference = a.findIndex((part, i) => part !== b[i]);
15
+ stale = difference !== -1 && a[difference] < b[difference];
16
+ }
17
+ return `npm latest (cached) ${latest} (${checked}${stale ? '; stale: older than installed' : ''})`;
18
+ }
19
+
20
+ const HELP = `passioncode — the PassionCode.ai skill set (family.json), for every agent on this machine
6
21
 
7
22
  npx @passioncode-ai/passioncode@latest update [--dry-run] [--json] install or update the whole set
8
23
  npx @passioncode-ai/passioncode@latest status [--json] what is installed where
@@ -18,6 +33,18 @@ function main(argv) {
18
33
  const json = rest.includes('--json');
19
34
  const quiet = rest.includes('--quiet');
20
35
  const out = (value, text) => process.stdout.write(json ? JSON.stringify(value, null, 2) + '\n' : text + '\n');
36
+ const options = {
37
+ update: ['--dry-run', '--json', '--quiet'], install: ['--dry-run', '--json', '--quiet'],
38
+ status: ['--json'], restore: ['--json'], uninstall: ['--json'], help: [], '--help': [], '-h': [],
39
+ };
40
+ const configArgs = rest.filter((arg) => arg !== '--json');
41
+ const invalid = cmd === 'config'
42
+ ? configArgs.length !== 3 || configArgs[0] !== 'set'
43
+ : Object.hasOwn(options, cmd) && rest.some((arg) => !options[cmd].includes(arg));
44
+ if (invalid) {
45
+ process.stderr.write(`passioncode: unsupported arguments for ${cmd}; run passioncode help.\n`);
46
+ return 2;
47
+ }
21
48
  try {
22
49
  if (cmd === 'update' || cmd === 'install') {
23
50
  const r = L.update({ dryRun: rest.includes('--dry-run') });
@@ -26,8 +53,8 @@ function main(argv) {
26
53
  out(r, [
27
54
  ...r.steps.map((s) => `${s.outcome.padEnd(8)} ${s.kind.padEnd(11)} ${s.detail}${s.error ? `\n ${s.error}` : ''}`),
28
55
  '',
29
- failed.length ? `${failed.length} step(s) failed — the rest were applied. Re-run after fixing, or \`passioncode restore\`.` : rest.includes('--dry-run') ? `Plan for PassionCode.ai ${r.version}: nothing was changed.` : `PassionCode.ai ${r.version}: done. Restart your agent — skills load at session start.`,
30
- r.moved.length ? `${r.moved.length} item(s) moved aside, restorable with \`passioncode restore\`.` : '',
56
+ failed.length ? `${failed.length} step(s) failed. Completed steps remain applied. Fix the cause and retry; use passioncode restore for displaced files.` : rest.includes('--dry-run') ? `Plan for PassionCode.ai ${r.version}: nothing was changed.` : `PassionCode.ai ${r.version}: done. Restart your agent — skills load at session start.`,
57
+ r.moved.length ? `${r.moved.length} recovery entry(s) saved; use \`passioncode restore\`.` : '',
31
58
  ].filter(Boolean).join('\n'));
32
59
  }
33
60
  return failed.length ? 1 : 0;
@@ -35,7 +62,7 @@ function main(argv) {
35
62
  if (cmd === 'status') {
36
63
  const s = L.status();
37
64
  out(s, [
38
- `installed ${s.installed || 'nothing'} · package ${s.package || '?'} · latest on npm ${s.latest || 'not checked'}${s.auto ? ' · auto-update on' : ' · auto-update off'}`,
65
+ `installed ${s.installed || 'nothing'} · package ${s.package || '?'} · ${cachedNpmStatus(s)}${s.auto ? ' · auto-update on' : ' · auto-update off'}`,
39
66
  ...s.members.map((m) => `${m.name.padEnd(22)} ${String(m.version).padEnd(8)} claude: ${m.claude}${m.legacy.length ? ` (+ legacy ${m.legacy.join(', ')})` : ''}${m.legacyMarketplaces.length ? ` (+ legacy marketplace ${m.legacyMarketplaces.join(', ')})` : ''} · hub: ${m.hub.filter((h) => h.ok).length}/${m.hub.length}${m.shadows.length ? ` · SHADOW: ${m.shadows.join(', ')}` : ''}`),
40
67
  ].join('\n'));
41
68
  return 0;
@@ -45,8 +72,8 @@ function main(argv) {
45
72
  out(r, r.length ? `Restored:\n ${r.join('\n ')}` : 'Nothing to restore.');
46
73
  return 0;
47
74
  }
48
- if (cmd === 'config' && rest[0] === 'set') {
49
- out(L.setConfig({ key: rest[1], value: rest[2] }), `update.auto = ${rest[2]}`);
75
+ if (cmd === 'config' && configArgs[0] === 'set') {
76
+ out(L.setConfig({ key: configArgs[1], value: configArgs[2] }), `update.auto = ${configArgs[2]}`);
50
77
  return 0;
51
78
  }
52
79
  if (cmd === 'uninstall') {
package/family.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "name": "passioncode",
3
3
  "owner": "passioncode-ai",
4
4
  "displayName": "PassionCode.ai",
5
- "description": "Every PassionCode.ai agent skill, for Claude Code and every other agent on the machine, updated as one set.",
5
+ "description": "The PassionCode.ai agent skills listed in this set, for Claude Code and every other agent on the machine, updated as one set.",
6
6
  "members": [
7
7
  {
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.5.4",
11
+ "ref": "v0.5.5",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
@@ -22,7 +22,7 @@
22
22
  "name": "observatory-log",
23
23
  "displayName": "Observatory Log",
24
24
  "repo": "passioncode-ai/project-observatory-dashboard",
25
- "ref": "v0.8.2",
25
+ "ref": "v0.10.0",
26
26
  "kind": "plugin",
27
27
  "path": "observatory/engine/skill/plugins/observatory-log",
28
28
  "legacyPluginIds": [
package/lib/launcher.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * ~/.claude/skills (a plain copy there shadows the plugin and serves its frozen
10
10
  * version forever); every other agent reads the hub through its own channel.
11
11
  */
12
- const { execFileSync, spawnSync } = require('child_process');
12
+ const { spawnSync } = require('child_process');
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
@@ -49,6 +49,14 @@ function writeJson(file, value) {
49
49
  }
50
50
  function lstat(p) { try { return fs.lstatSync(p); } catch (_) { return null; } }
51
51
  function linkTarget(p) { try { return fs.readlinkSync(p); } catch (_) { return null; } }
52
+ function pointsTo(at, target) { const link = linkTarget(at); return link !== null && path.resolve(path.dirname(at), link) === target; }
53
+ function isDirectory(at) { try { return fs.statSync(at).isDirectory(); } catch (_) { return false; } }
54
+ function readState(file, { tolerant = false } = {}) {
55
+ try { return readSettings(file).doc; } catch (error) {
56
+ if (tolerant) return {};
57
+ throw new Error(`Cannot read state ${file}: ${error.message}. Preserve the file and repair it before retrying.`);
58
+ }
59
+ }
52
60
 
53
61
  /** Replace a symlink atomically (create beside it, rename over it). */
54
62
  function symlinkAtomic(target, at) {
@@ -62,7 +70,8 @@ function symlinkAtomic(target, at) {
62
70
  /** Claude Code's plugin CLI, overridable for tests. */
63
71
  function claudeRunner() {
64
72
  const bin = process.env.PASSIONCODE_CLAUDE || 'claude';
65
- const available = () => spawnSync(bin, ['--version'], { encoding: 'utf8' }).status === 0;
73
+ let found;
74
+ const available = () => found ?? (found = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 }).status === 0);
66
75
  const run = (args, { cwd } = {}) => {
67
76
  const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 180000, ...(cwd ? { cwd } : {}) });
68
77
  return { code: r.status === null ? 1 : r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
@@ -93,15 +102,41 @@ function marketplaceAddArg(source) {
93
102
  function payloadManifest(dir = PAYLOAD()) {
94
103
  const m = readJson(path.join(dir, 'manifest.json'), null);
95
104
  if (!m || !Array.isArray(m.members)) throw new Error(`No payload in ${dir}: the package is incomplete (run scripts/vendor.mjs).`);
105
+ const name = (value) => typeof value === 'string' && /^[a-z0-9][a-z0-9._-]*$/.test(value);
106
+ const version = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
107
+ const names = new Set(), skills = new Set();
108
+ if (!version(m.version)) throw new Error(`Invalid payload version in ${dir}.`);
109
+ for (const member of m.members) {
110
+ if (!member || !name(member.name) || names.has(member.name) || !version(member.version) || !Array.isArray(member.skills)) {
111
+ throw new Error(`Invalid or duplicate payload member in ${dir}.`);
112
+ }
113
+ names.add(member.name);
114
+ for (const skill of member.skills) {
115
+ if (!name(skill) || skills.has(skill)) throw new Error(`Invalid or duplicate skill in ${dir}.`);
116
+ skills.add(skill);
117
+ }
118
+ }
96
119
  return m;
97
120
  }
98
121
 
122
+ function verifyPayload(dir, manifest) {
123
+ for (const m of manifest.members) {
124
+ const root = path.join(dir, 'plugins', m.name);
125
+ const plugin = readJson(path.join(root, '.claude-plugin/plugin.json'), null);
126
+ if (!plugin || plugin.name !== m.name || plugin.version !== m.version) throw new Error(`Payload plugin mismatch: ${m.name}.`);
127
+ for (const skill of m.skills) {
128
+ if (!fs.existsSync(path.join(root, 'skills', skill, 'SKILL.md'))) throw new Error(`Payload skill missing: ${m.name}/${skill}.`);
129
+ }
130
+ }
131
+ }
132
+
99
133
  /** Plan and apply an update. Returns the steps with their outcomes. */
100
134
  function update({ home, dryRun = false, claude = claudeRunner(), log = () => {} } = {}) {
101
135
  const p = paths(home);
102
136
  const manifest = payloadManifest();
137
+ verifyPayload(PAYLOAD(), manifest);
103
138
  const version = manifest.version;
104
- const state = readJson(p.stateFile, {});
139
+ const state = readState(p.stateFile);
105
140
  const steps = [];
106
141
  const step = (kind, detail, fn) => {
107
142
  if (dryRun) { steps.push({ kind, detail, outcome: 'planned' }); return true; }
@@ -115,12 +150,19 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
115
150
  }
116
151
  };
117
152
  const moved = [];
118
- const quarantineDir = path.join(p.quarantine, new Date().toISOString().replace(/[:.]/g, '-'));
119
- const quarantine = (target, why) => {
153
+ const quarantineDir = path.join(p.quarantine, `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`);
154
+ const recordMove = (entry) => {
155
+ writeJson(path.join(quarantineDir, 'moved.json'), [...moved, entry]);
156
+ const fresh = readState(p.stateFile);
157
+ fresh.quarantines = [...new Set([...(fresh.quarantines || []), quarantineDir])];
158
+ writeJson(p.stateFile, fresh);
159
+ moved.push(entry);
160
+ };
161
+ const quarantine = (target, why, replacement = null) => {
120
162
  const dest = path.join(quarantineDir, target.replace(/^\//, '').replace(/\//g, '__'));
121
163
  fs.mkdirSync(path.dirname(dest), { recursive: true });
164
+ recordMove({ from: target, to: dest, why, replacement });
122
165
  fs.renameSync(target, dest);
123
- moved.push({ from: target, to: dest, why });
124
166
  };
125
167
 
126
168
  /** Move a legacy marketplace out of settings.json and the registry, recorded for restore. */
@@ -151,11 +193,13 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
151
193
  record.settingsBackup = path.join(quarantineDir, `settings.json.before-${name}`);
152
194
  fs.copyFileSync(p.claudeSettings, record.settingsBackup);
153
195
  record.settingsEntry = declared;
196
+ }
197
+ recordMove(record); // durable before settings or the registry changes
198
+ if (declared !== undefined) {
154
199
  const doc = settings.doc;
155
200
  delete doc.extraKnownMarketplaces[name];
156
201
  writeJson(p.claudeSettings, doc);
157
202
  }
158
- moved.push(record); // recorded before the CLI runs, so restore can undo a half-finished step
159
203
  if (registered) {
160
204
  // No --scope: with the user entry already moved it would refuse. From ~/.passioncode
161
205
  // "every scope" is the user one — no project's .claude/settings.json is touched.
@@ -168,14 +212,22 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
168
212
  // 1. The release: an immutable copy of the payload, then `current` points at it.
169
213
  const release = path.join(p.releases, version);
170
214
  step('release', `install ${version} at ${release}`, () => {
171
- if (lstat(path.join(release, 'manifest.json'))) return false;
215
+ if (lstat(path.join(release, 'manifest.json'))) {
216
+ const existing = payloadManifest(release);
217
+ verifyPayload(release, existing);
218
+ const identity = (m) => JSON.stringify([m.version, m.members.map(({ via, ...member }) => member)]);
219
+ if (identity(existing) !== identity(manifest)) throw new Error(`Release ${version} has a different manifest; use a new version instead of overwriting an immutable release.`);
220
+ return false;
221
+ }
172
222
  const tmp = `${release}.${process.pid}.partial`;
173
223
  fs.rmSync(tmp, { recursive: true, force: true });
174
224
  fs.cpSync(PAYLOAD(), tmp, { recursive: true });
175
225
  fs.renameSync(tmp, release);
176
226
  return true;
177
227
  });
228
+ if (steps.at(-1).outcome === 'failed') return { version, steps, moved };
178
229
  step('current', `point ${p.current} at ${version}`, () => { symlinkAtomic(release, p.current); });
230
+ if (steps.at(-1).outcome === 'failed') return { version, steps, moved };
179
231
 
180
232
  // 2. Claude Code: one local marketplace, one plugin per member.
181
233
  const hasClaude = claude.available();
@@ -185,16 +237,20 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
185
237
  steps.push({ kind: 'claude', detail: 'Claude Code CLI not found; its plugins were not installed', outcome: 'skipped' });
186
238
  } else {
187
239
  const known = readJson(path.join(p.claudePlugins, 'known_marketplaces.json'), {});
188
- step('marketplace', known[MARKETPLACE] ? `update the ${MARKETPLACE} marketplace` : `add ${p.current} as the ${MARKETPLACE} marketplace`, () => {
240
+ const marketplaceReady = step('marketplace', known[MARKETPLACE] ? `update the ${MARKETPLACE} marketplace` : `add ${p.current} as the ${MARKETPLACE} marketplace`, () => {
189
241
  const r = known[MARKETPLACE] ? claude.run(['plugin', 'marketplace', 'update', MARKETPLACE]) : claude.run(['plugin', 'marketplace', 'add', p.current]);
190
242
  if (r.code !== 0) throw new Error(r.out || 'marketplace command failed');
191
243
  });
192
- for (const m of manifest.members) {
244
+ for (const m of marketplaceReady ? manifest.members : []) {
193
245
  const id = `${m.name}@${MARKETPLACE}`;
194
246
  const present = installedIds().includes(id);
195
247
  const ok = step('plugin', `${present ? 'update' : 'install'} ${id} ${m.version}`, () => {
196
248
  const r = claude.run(['plugin', present ? 'update' : 'install', id]);
197
249
  if (r.code !== 0 && !/already (installed|at the latest)/i.test(r.out)) throw new Error(r.out || `plugin ${present ? 'update' : 'install'} failed`);
250
+ const entries = readJson(path.join(p.claudePlugins, 'installed_plugins.json'), { plugins: {} }).plugins?.[id];
251
+ if (!Array.isArray(entries) || !entries.some((entry) => entry.version === m.version)) {
252
+ throw new Error(`Claude Code did not register ${id} at version ${m.version}; legacy entries were kept.`);
253
+ }
198
254
  });
199
255
  if (ok && (dryRun || installedIds().includes(id))) verified.add(m.name);
200
256
  // The same plugin from its old single-member marketplace is a second copy of every skill.
@@ -218,7 +274,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
218
274
  }
219
275
 
220
276
  // 3. Every other agent: hub link → the release, channel link → the hub.
221
- const channels = CHANNELS.map((c) => path.join(p.home, c)).filter((c) => lstat(c) && lstat(c).isDirectory());
277
+ const channels = CHANNELS.map((c) => path.join(p.home, c)).filter(isDirectory);
222
278
  for (const m of manifest.members) {
223
279
  for (const skill of m.skills) {
224
280
  const source = path.join(p.current, 'plugins', m.name, 'skills', skill);
@@ -226,7 +282,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
226
282
  const existing = lstat(hubLink);
227
283
  if (!existing || linkTarget(hubLink) !== source) {
228
284
  step('hub', `${hubLink} → ${source}`, () => {
229
- if (lstat(hubLink)) quarantine(hubLink, existing.isSymbolicLink() ? `pointed at ${linkTarget(hubLink)}` : 'a plain copy');
285
+ if (lstat(hubLink)) quarantine(hubLink, existing.isSymbolicLink() ? `pointed at ${linkTarget(hubLink)}` : 'a plain copy', source);
230
286
  symlinkAtomic(source, hubLink);
231
287
  });
232
288
  }
@@ -235,7 +291,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
235
291
  const cur = lstat(at);
236
292
  if (cur && cur.isSymbolicLink() && (linkTarget(at) === hubLink || path.resolve(channel, linkTarget(at) || '') === hubLink)) continue;
237
293
  step('channel', `${at} → ${hubLink}`, () => {
238
- if (lstat(at)) quarantine(at, cur.isSymbolicLink() ? `pointed at ${linkTarget(at)}` : 'a plain copy');
294
+ if (lstat(at)) quarantine(at, cur.isSymbolicLink() ? `pointed at ${linkTarget(at)}` : 'a plain copy', hubLink);
239
295
  symlinkAtomic(hubLink, at);
240
296
  });
241
297
  }
@@ -249,12 +305,15 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
249
305
 
250
306
  if (!dryRun) {
251
307
  if (moved.length) writeJson(path.join(quarantineDir, 'moved.json'), moved);
252
- state.installed = version;
253
- state.installedAt = new Date().toISOString();
254
- state.members = manifest.members.map((m) => ({ name: m.name, version: m.version, ref: m.ref, commit: m.commit }));
255
- state.quarantines = [...(state.quarantines || []), ...(moved.length ? [quarantineDir] : [])];
256
- delete state.updatingSince;
257
- writeJson(p.stateFile, state);
308
+ // A probe or config command may have written while the plugin CLI ran.
309
+ const fresh = readState(p.stateFile);
310
+ fresh.installed = version;
311
+ fresh.installedAt = new Date().toISOString();
312
+ fresh.members = manifest.members.map((m) => ({ name: m.name, version: m.version, ref: m.ref, commit: m.commit }));
313
+ fresh.quarantines = [...new Set([...(fresh.quarantines || []), ...(moved.length ? [quarantineDir] : [])])];
314
+ if (state.legacyRemoved) fresh.legacyRemoved = state.legacyRemoved;
315
+ delete fresh.updatingSince;
316
+ writeJson(p.stateFile, fresh);
258
317
  }
259
318
  log(`${dryRun ? 'Plan' : 'Updated'}: PassionCode.ai ${version}`);
260
319
  return { version, steps, moved };
@@ -263,17 +322,36 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
263
322
  /** Put back everything a run moved aside (newest run first unless one is named). */
264
323
  function restore({ home, which, claude = claudeRunner() } = {}) {
265
324
  const p = paths(home);
266
- const state = readJson(p.stateFile, {});
325
+ const state = readState(p.stateFile);
267
326
  const dirs = which ? [which] : (state.quarantines || []).slice(-1);
268
327
  const restored = [];
269
328
  for (const dir of dirs) {
270
- for (const entry of readJson(path.join(dir, 'moved.json'), []).reverse()) {
271
- if (entry.kind === 'marketplace') { restored.push(restoreMarketplace(p, entry, claude)); continue; }
272
- if (lstat(entry.from) && lstat(entry.from).isSymbolicLink()) fs.unlinkSync(entry.from);
273
- if (lstat(entry.from)) continue;
274
- fs.mkdirSync(path.dirname(entry.from), { recursive: true });
275
- fs.renameSync(entry.to, entry.from);
276
- restored.push(entry.from);
329
+ const journal = path.join(dir, 'moved.json');
330
+ const entries = JSON.parse(fs.readFileSync(journal, 'utf8'));
331
+ for (const entry of [...entries].reverse()) {
332
+ if (entry.restoredAt) continue;
333
+ if (entry.kind === 'marketplace') {
334
+ restored.push(restoreMarketplace(p, entry, claude));
335
+ } else if (lstat(entry.to)) {
336
+ if (lstat(entry.from)) {
337
+ // Old journals did not record the replacement. Only recognise a link
338
+ // into this launcher's hub/current tree; never erase an arbitrary link.
339
+ const target = linkTarget(entry.from);
340
+ const resolved = target === null ? null : path.resolve(path.dirname(entry.from), target);
341
+ const owned = entry.replacement !== undefined ? resolved === entry.replacement && resolved !== null
342
+ : resolved && (resolved.startsWith(p.current + path.sep) || resolved.startsWith(p.hub + path.sep));
343
+ if (!owned) throw new Error(`Restore conflict at ${entry.from}; move the replacement aside and retry.`);
344
+ fs.unlinkSync(entry.from);
345
+ }
346
+ fs.mkdirSync(path.dirname(entry.from), { recursive: true });
347
+ fs.renameSync(entry.to, entry.from);
348
+ restored.push(entry.from);
349
+ } else if (!lstat(entry.from)) {
350
+ throw new Error(`Restore source missing: ${entry.to}; preserve the quarantine for recovery.`);
351
+ }
352
+ // Also recognise already-restored entries from releases with no checkpoint.
353
+ entry.restoredAt = new Date().toISOString();
354
+ writeJson(journal, entries);
277
355
  }
278
356
  }
279
357
  return restored;
@@ -303,12 +381,13 @@ function restoreMarketplace(p, entry, claude) {
303
381
  notes.push(`settings.json not restored (${error.message}); the original is at ${entry.settingsBackup}`);
304
382
  }
305
383
  }
306
- return `marketplace ${entry.name}${notes.length ? ` (${notes.join('; ')})` : ''}`;
384
+ if (notes.length) throw new Error(`Could not fully restore marketplace ${entry.name}: ${notes.join('; ')}. Retry restore after fixing it.`);
385
+ return `marketplace ${entry.name}`;
307
386
  }
308
387
 
309
388
  function status({ home, claude = claudeRunner() } = {}) {
310
389
  const p = paths(home);
311
- const state = readJson(p.stateFile, {});
390
+ const state = readState(p.stateFile, { tolerant: true });
312
391
  let payload = null;
313
392
  try { payload = payloadManifest(); } catch (_) { /* reported as unknown */ }
314
393
  // What is installed is the release `current` points at. The payload beside this CLI is
@@ -330,7 +409,7 @@ function status({ home, claude = claudeRunner() } = {}) {
330
409
  shadows: m.skills.filter((s) => lstat(path.join(p.claudeSkills, s))),
331
410
  }));
332
411
  return {
333
- installed: state.installed || (release ? release.version : null), latest: state.latest || null, checkedAt: state.checkedAt || null,
412
+ installed: release ? release.version : (state.installed || null), latest: state.latest || null, checkedAt: state.checkedAt || null,
334
413
  package: payload ? payload.version : null, source: release ? 'installed' : (payload ? 'package' : null),
335
414
  auto: (state.config || {}).auto !== false, members,
336
415
  };
@@ -339,7 +418,7 @@ function status({ home, claude = claudeRunner() } = {}) {
339
418
  function setConfig({ home, key, value }) {
340
419
  if (key !== 'update.auto' || !['on', 'off'].includes(value)) throw new Error('Only `config set update.auto on|off` is supported.');
341
420
  const p = paths(home);
342
- const state = readJson(p.stateFile, {});
421
+ const state = readState(p.stateFile);
343
422
  state.config = { ...(state.config || {}), auto: value === 'on' };
344
423
  writeJson(p.stateFile, state);
345
424
  return state.config;
@@ -347,24 +426,49 @@ function setConfig({ home, key, value }) {
347
426
 
348
427
  function uninstall({ home, claude = claudeRunner() } = {}) {
349
428
  const p = paths(home);
350
- let manifest;
351
- try { manifest = payloadManifest(p.current); } catch (_) { manifest = { members: [] }; }
429
+ readState(p.stateFile); // refuse to overwrite a damaged recovery record
430
+ const manifest = lstat(p.current) ? payloadManifest(p.current) : { members: [] };
352
431
  const removed = [];
432
+ const failures = [];
433
+ const hasClaude = claude.available();
434
+ const installedIds = () => Object.keys(readJson(path.join(p.claudePlugins, 'installed_plugins.json'), { plugins: {} }).plugins || {});
353
435
  for (const m of manifest.members) {
354
436
  for (const skill of m.skills) {
355
437
  const hubLink = path.join(p.hub, skill);
356
- if (linkTarget(hubLink) === path.join(p.current, 'plugins', m.name, 'skills', skill)) { fs.unlinkSync(hubLink); removed.push(hubLink); }
438
+ if (pointsTo(hubLink, path.join(p.current, 'plugins', m.name, 'skills', skill))) { fs.unlinkSync(hubLink); removed.push(hubLink); }
357
439
  for (const c of CHANNELS) {
358
440
  const at = path.join(p.home, c, skill);
359
- if (linkTarget(at) === hubLink) { fs.unlinkSync(at); removed.push(at); }
441
+ if (pointsTo(at, hubLink)) { fs.unlinkSync(at); removed.push(at); }
360
442
  }
361
443
  }
362
- if (claude.available()) {
363
- const r = claude.run(['plugin', 'uninstall', `${m.name}@${MARKETPLACE}`]);
364
- if (r.code === 0) removed.push(`${m.name}@${MARKETPLACE}`);
444
+ const id = `${m.name}@${MARKETPLACE}`;
445
+ if (installedIds().includes(id)) {
446
+ if (!hasClaude) { failures.push(`${id}: Claude Code CLI not found`); continue; }
447
+ const r = claude.run(['plugin', 'uninstall', id]);
448
+ if (r.code === 0 && !installedIds().includes(id)) removed.push(id);
449
+ else failures.push(`${id}: ${r.out || 'plugin remains registered after uninstall'}`);
365
450
  }
366
451
  }
367
- if (claude.available()) claude.run(['plugin', 'marketplace', 'remove', MARKETPLACE]);
452
+ const known = readJson(path.join(p.claudePlugins, 'known_marketplaces.json'), {});
453
+ if (!failures.length && known[MARKETPLACE] && !installedIds().some((id) => id.endsWith(`@${MARKETPLACE}`))) {
454
+ if (!hasClaude) failures.push('Claude Code CLI not found; marketplace remains registered');
455
+ else {
456
+ const r = claude.run(['plugin', 'marketplace', 'remove', MARKETPLACE]);
457
+ if (r.code !== 0) failures.push(r.out || 'marketplace remove failed');
458
+ }
459
+ }
460
+ if (failures.length) throw new Error(`Uninstall incomplete: ${failures.join('; ')}. Fix the error and retry uninstall.`);
461
+ if (lstat(p.current)) {
462
+ if (!lstat(p.current).isSymbolicLink()) throw new Error(`Refusing to remove non-symlink ${p.current}.`);
463
+ fs.unlinkSync(p.current);
464
+ }
465
+ const state = readState(p.stateFile);
466
+ state.installed = null;
467
+ state.members = [];
468
+ state.config = { ...(state.config || {}), auto: false };
469
+ delete state.installedAt;
470
+ delete state.updatingSince;
471
+ writeJson(p.stateFile, state);
368
472
  return removed;
369
473
  }
370
474
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.13",
4
- "description": "Every PassionCode.ai agent skill — Fabric Agent Adapter, Observatory Log and the organisation's working rules — for Claude Code and every other agent on the machine, updated as one set.",
3
+ "version": "0.1.16",
4
+ "description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
7
7
  },
@@ -23,8 +23,8 @@
23
23
  "scripts": {
24
24
  "vendor": "node scripts/vendor.mjs",
25
25
  "vendor:release": "node scripts/vendor.mjs --release",
26
- "test": "node --test 'test/*.test.js'",
27
- "prepublishOnly": "node scripts/vendor.mjs --release && node --test 'test/*.test.js'"
26
+ "test": "node scripts/test.mjs",
27
+ "prepublishOnly": "node scripts/vendor.mjs --release && node scripts/test.mjs"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=18"
@@ -5,13 +5,13 @@
5
5
  "name": "PassionCode.ai",
6
6
  "url": "https://passioncode.ai/"
7
7
  },
8
- "description": "Every PassionCode.ai agent skill, for Claude Code and every other agent on the machine, updated as one set.",
8
+ "description": "The PassionCode.ai agent skills listed in this set, for Claude Code and every other agent on the machine, updated as one set.",
9
9
  "plugins": [
10
10
  {
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.5.4",
14
+ "version": "0.5.5",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -23,19 +23,19 @@
23
23
  "name": "observatory-log",
24
24
  "displayName": "Observatory Log",
25
25
  "source": "./plugins/observatory-log",
26
- "version": "0.12.3",
26
+ "version": "0.13.0",
27
27
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
28
28
  "author": {
29
29
  "name": "PassionCode.ai",
30
30
  "url": "https://passioncode.ai/"
31
31
  },
32
- "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0"
32
+ "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial"
33
33
  },
34
34
  {
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.13",
38
+ "version": "0.1.16",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.5.4",
11
- "commit": "358c5296d14c593760c73795fa17c77658f9286c",
12
- "version": "0.5.4",
10
+ "ref": "v0.5.5",
11
+ "commit": "f302123b97b0c4aaf2b447f6054b583b62b3caf3",
12
+ "version": "0.5.5",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:577bc17f08e963b7b3ddea55f1c50d9e4c5f0756180f59fe0f298ca055684a9d",
25
+ "contentHash": "sha256:6e4ba6f36e9d2f62a621a1dee0d0feb5532c7e959acdf4bfa5c8edfa82304711",
26
26
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -34,9 +34,9 @@
34
34
  "name": "observatory-log",
35
35
  "displayName": "Observatory Log",
36
36
  "repo": "passioncode-ai/project-observatory-dashboard",
37
- "ref": "v0.8.2",
38
- "commit": "4f3591de65cd9a28de18212d53da966405f46f22",
39
- "version": "0.12.3",
37
+ "ref": "v0.10.0",
38
+ "commit": "e1e0110f6618b9541f3e204a548c283005cb6fa9",
39
+ "version": "0.13.0",
40
40
  "via": "clone of https://***@github.com/passioncode-ai/project-observatory-dashboard.git",
41
41
  "skills": [
42
42
  "explaining-changes",
@@ -49,28 +49,28 @@
49
49
  "legacyMarketplaces": [
50
50
  "observatory-log"
51
51
  ],
52
- "contentHash": "sha256:0ddb7efdfd9977c55d65e58a3cd8dc9cb9da3b8eb9ab02c83e3a159ea233017b",
52
+ "contentHash": "sha256:1514d04e995ecd71df0e0518787d76c9320b02f8174824d08c958846b860fd42",
53
53
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
54
54
  "author": {
55
55
  "name": "PassionCode.ai",
56
56
  "url": "https://passioncode.ai/"
57
57
  },
58
- "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0"
58
+ "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial"
59
59
  },
60
60
  {
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.13",
64
+ "ref": "v0.1.16",
65
65
  "commit": null,
66
- "version": "0.1.13",
66
+ "version": "0.1.16",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:8ee5265a68efc3d68b92f2e4526b0d4280282bf08cae8ee55ed383b5b0fd4c10",
73
+ "contentHash": "sha256:15d7a746ecf165e574f87a28af46bd0ea04255b86cc3351ee0d96778bb741447",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.5.4",
6
+ "version": "0.5.5",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -2,10 +2,10 @@
2
2
  name: adapting-projects-to-fabric
3
3
  description: Use when adapting an existing agent, MCP server, A2A peer, HTTP service, or terminal CLI to the Fabric Agent Contract—choosing MCP/A2A/local-runner, scaffolding a provider bundle, or checking Fabric compatibility; also for «подключить проект к Fabric» or «сделать агента совместимым с Fabric». NOT for building the Fabric host/orchestrator, configuring an MCP client, or a brand-new agent — that is creating-fabric-agents.
4
4
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
- compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode.
5
+ compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.5.4"
8
+ version: "0.5.5"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
11
11
  ---
@@ -74,7 +74,7 @@ Use exactly:
74
74
 
75
75
  Read the pinned contract's guide
76
76
  `docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
77
- the referenced JSON Schemas before implementing protocol details. If the private checkout
77
+ the referenced JSON Schemas before implementing protocol details. If the contract checkout
78
78
  is unavailable, continue only through local structure and mark exact schema validation
79
79
  `NOT_RUN`; do not reconstruct missing normative rules from memory.
80
80
 
@@ -15,7 +15,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
15
15
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). No network or package install; the contract checkout is optional.
16
16
  metadata:
17
17
  author: PassionCode.ai
18
- version: "0.5.4"
18
+ version: "0.5.5"
19
19
  contract-version: "0.1.0"
20
20
  extension: "fabric-service/0.1"
21
21
  extension-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
@@ -2,10 +2,10 @@
2
2
  name: creating-fabric-agents
3
3
  description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit — «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch". Runs the intake grill (capability, named consumer, workflow-or-agent, MCP/A2A/local-runner profile, effect declarations), distils source projects into a knowledge pack whose recorded failures become planted eval fixtures, scaffolds the pinned contract bundle, and sets the two-clock eval expectation with the conformance report. NOT for adapting an existing project (use adapting-projects-to-fabric), building the Fabric host or orchestrator, or generic agent design where Fabric compatibility is not part of the request.
4
4
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
- compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
5
+ compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.5.4"
8
+ version: "0.5.5"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
11
11
  ---
@@ -3,8 +3,8 @@
3
3
  "name": "observatory-log",
4
4
  "displayName": "Observatory Log",
5
5
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
6
- "version": "0.12.3",
7
- "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0",
6
+ "version": "0.13.0",
7
+ "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
8
8
  "author": {
9
9
  "name": "PassionCode.ai",
10
10
  "url": "https://passioncode.ai/"
@@ -13,9 +13,9 @@ description: >-
13
13
  message, a PR description or a changelog, and NOT for changing the typed
14
14
  registry — a registry change is `observatory_propose`, which is a different
15
15
  decision with different evidence.
16
- license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
16
+ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
17
17
  metadata:
18
- version: "0.12.3"
18
+ version: "0.13.0"
19
19
  compatibility: >-
20
20
  Requires an initialized full Project Observatory installation to persist
21
21
  records. Uses its MCP server when available in the current host; the bundled
@@ -7,9 +7,9 @@ description: >-
7
7
  Keeps credential values out of prompts and command arguments, uses named slots,
8
8
  and records changes without copying values into reports. NOT for granting
9
9
  provider permissions or choosing a project's authentication architecture.
10
- license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
10
+ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
11
11
  metadata:
12
- version: "0.12.3"
12
+ version: "0.13.0"
13
13
  compatibility: >-
14
14
  Requires an initialized full Project Observatory installation, Python 3.11+
15
15
  and local shell access on macOS or Linux. Provider operations additionally
@@ -29,7 +29,7 @@ names. Never ask the user to paste a credential into the conversation.
29
29
  2. Select the user's initialized `OBSERVATORY_HOME`. Run
30
30
  `python3 "$OBSERVATORY_ROOT/observatory.py" doctor`. A missing workspace
31
31
  needs the documented onboarding before secret operations.
32
- 3. Run `python3 "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.12.3`.
32
+ 3. Run `python3 "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.13.0`.
33
33
  If stale, read the installed skill once and follow its compatible commands.
34
34
  Do not turn an unavailable version check into a retry loop.
35
35
  4. Inspect names using `tools/use_secret.py names PROJECT` or `tools/vault.py
@@ -90,6 +90,47 @@ Zone Read and DNS Write on that zone only, verified against its records and
90
90
  delivered to the vault slot on stdin; a second issue rolls the same token. Not
91
91
  an account-wide DNS token, and never the admin token in a script.
92
92
 
93
+ A project whose Worker keeps data in D1 and must write it from a machine
94
+ (`wrangler d1 execute --remote`) gets `cloudflare.py issue --preset d1-edit
95
+ --account <slug> --vault <project>/<env>/<NAME>`: D1 Write on that one account,
96
+ verified by listing its databases, delivered on stdin; a second issue rolls the
97
+ same token. The result prints the account id — an account-owned token cannot
98
+ find its account by itself, so pass it as `CLOUDFLARE_ACCOUNT_ID`.
99
+
100
+ A project that must put and read objects in one R2 bucket (an off-site backup)
101
+ gets `cloudflare.py issue --preset r2-bucket --account <slug> --bucket <name>
102
+ [--jurisdiction eu] [--expire-days 30] --vault <project>/<env>/<PREFIX>`.
103
+ The door creates the bucket if it is missing and sets its lifecycle through a
104
+ setup token that it deletes before returning. It then grants Bucket Item Write on that
105
+ bucket's resource only. Before anything is delivered, the S3 pair must prove a
106
+ put, a get and a delete, and must be refused a bucket list. It lands in three
107
+ slots on stdin: `<PREFIX>_ACCESS_KEY_ID`, `<PREFIX>_SECRET_ACCESS_KEY` and
108
+ `<PREFIX>_ENDPOINT`. A second issue rolls the same token and keeps the key id.
109
+
110
+ An application that sends transactional email through Cloudflare Email Service
111
+ gets `cloudflare.py issue --preset email-send --account <slug> --vault
112
+ <project>/<env>/<NAME>`: Email Sending Write and Read on that one account (the
113
+ group exists at account level only), verified by listing its suppressions.
114
+ Adding an Email Routing rule in one zone — one address to a Worker — is
115
+ `--preset email-routing --zone <zone>`, and deploying that Worker with its KV
116
+ namespace is `--preset workers-edit --account <slug>`. Each is its own token in
117
+ its own slot. When a preset's groups are unknown, `cloudflare.py groups
118
+ --account <slug> --match "<words>"` lists the catalogue's names and levels,
119
+ read-only and without ids.
120
+
121
+ A Fabric Inbox server's own token is `--preset fabric-inbox-server --account
122
+ <slug> --vault <project>/<env>/<NAME>`; a token for one more account whose
123
+ domains that server reads is `--preset fabric-inbox-account` issued from THAT
124
+ account. Each carries the product's own permission list as two policies —
125
+ account-level groups on the account, zone-level groups (Zone Read, Email
126
+ Routing Rules, Zone Settings, DNS Write) on every zone of that account only —
127
+ is verified by listing the account's Workers, and is named after its slot.
128
+ The other-account token makes no storage and no sign-in there.
129
+
130
+ Reading one account's Worker logs is `--preset workers-observability-read
131
+ --account <slug> --vault <project>/<env>/<NAME>`: Workers Observability Read
132
+ only, verified by the telemetry-keys query with the new value.
133
+
93
134
  By default, slots live under the private workspace's `secrets/projects/`.
94
135
  An explicitly configured `sources.secret_store` or `OBSERVATORY_VAULT_DIR`
95
136
  can select a separate private store. Such external stores are excluded from
@@ -13,9 +13,9 @@ description: >-
13
13
  figma-use), wiring analytics or pixels (ad-tracking), provisioning the resource
14
14
  with the provider's own tools, secrets (handling-secrets) or code changes inside
15
15
  the repository.
16
- license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
16
+ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
17
17
  metadata:
18
- version: "0.12.3"
18
+ version: "0.13.0"
19
19
  compatibility: >-
20
20
  Requires a full Project Observatory installation with organizations.json
21
21
  configured; the observatory MCP server or its local CLI. Creating the resource
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.1.13",
5
+ "version": "0.1.16",
6
6
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
@@ -32,7 +32,7 @@ function originOf(dir) {
32
32
  /** The one line to print at session start in `dir`, or null. */
33
33
  function rulesLine(dir) {
34
34
  if (!orgOf(originOf(dir))) return null;
35
- return `[passioncode] passioncode-ai repository: read the knowledge base (${KNOWLEDGE} or ${KNOWLEDGE_WEB}), then this repository's AGENTS.md, before the first edit; after the work, update the knowledge base page that owns any cross-repository fact you changed.`;
35
+ return `[passioncode] passioncode-ai repository: read the knowledge base (${KNOWLEDGE} or ${KNOWLEDGE_WEB}), then this repository's AGENTS.md, before the first edit; after the work, update the knowledge base page that owns any cross-repository fact you changed; edit each canonical task source declared in docs/backlog-sources.json and publish the derived workspace backlog.`;
36
36
  }
37
37
 
38
38
  module.exports = { KNOWLEDGE, KNOWLEDGE_WEB, orgOf, rulesLine };
@@ -16,24 +16,32 @@ const DIR = process.env.PASSIONCODE_HOME || path.join(os.homedir(), '.passioncod
16
16
  const STATE = path.join(DIR, 'state.json');
17
17
  const DAY = 24 * 60 * 60 * 1000;
18
18
 
19
- function read(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return fallback; } }
19
+ function read(file, fallback) {
20
+ try { const doc = JSON.parse(fs.readFileSync(file, 'utf8')); return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : fallback; }
21
+ catch (_) { return fallback; }
22
+ }
20
23
  function detached(cmd, args, logName) {
21
- try {
22
- fs.mkdirSync(path.join(DIR, 'logs'), { recursive: true });
23
- const out = fs.openSync(path.join(DIR, 'logs', logName), 'a');
24
- spawn(cmd, args, { detached: true, stdio: ['ignore', out, out], env: process.env }).unref();
25
- return true;
26
- } catch (_) { return false; /* a missing npm must not break the session */ }
24
+ return new Promise((resolve) => {
25
+ let out;
26
+ try {
27
+ fs.mkdirSync(path.join(DIR, 'logs'), { recursive: true });
28
+ out = fs.openSync(path.join(DIR, 'logs', logName), 'a');
29
+ const child = spawn(cmd, args, { detached: true, stdio: ['ignore', out, out], env: process.env });
30
+ child.once('error', () => resolve(false));
31
+ child.once('spawn', () => { child.unref(); resolve(true); });
32
+ } catch (_) { resolve(false); }
33
+ finally { if (out !== undefined) fs.closeSync(out); }
34
+ });
27
35
  }
28
36
 
29
- try {
37
+ async function main() {
30
38
  const state = read(STATE, {});
31
39
  const now = Date.now();
32
- if (!state.checkedAt || !(now - Date.parse(state.checkedAt) <= DAY)) {
33
- detached(process.execPath, [path.join(__dirname, 'probe.js')], 'probe.log');
40
+ const age = now - Date.parse(state.checkedAt);
41
+ if (!Number.isFinite(age) || age < 0 || age > DAY) {
42
+ await detached(process.execPath, [path.join(__dirname, 'probe.js')], 'probe.log');
34
43
  }
35
44
  const { lines, spawn: args, stateChanges } = decide(state, loadTrust(), now);
36
- if (args && !detached('npx', args, 'update.log')) delete stateChanges.updatingSince;
37
45
  if (Object.keys(stateChanges).length) {
38
46
  // Re-read: the probe may have written meanwhile; only our own keys change.
39
47
  const fresh = { ...read(STATE, {}), ...stateChanges };
@@ -42,10 +50,24 @@ try {
42
50
  fs.writeFileSync(tmp, JSON.stringify(fresh, null, 2));
43
51
  fs.renameSync(tmp, STATE);
44
52
  }
53
+ // Record the attempt before spawning: a fast update must not finish and then
54
+ // have this hook put the old "running" marker back.
55
+ if (args && !await detached('npx', args, 'update.log')) {
56
+ const fresh = read(STATE, {});
57
+ if (fresh.updatingSince === stateChanges.updatingSince) {
58
+ delete fresh.updatingSince;
59
+ const tmp = `${STATE}.${process.pid}.hook`;
60
+ fs.writeFileSync(tmp, JSON.stringify(fresh, null, 2));
61
+ fs.renameSync(tmp, STATE);
62
+ }
63
+ lines.pop(); // decide appends the update-start notice last when it returns args
64
+ lines.push('[passioncode] could not start the background update; check that npx is on PATH and retry next session.');
65
+ }
45
66
  // Claude Code passes the session's directory on stdin; the process cwd is the fallback.
46
67
  let cwd = process.cwd();
47
68
  try { const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); if (input && typeof input.cwd === 'string') cwd = input.cwd; } catch (_) { /* no stdin */ }
48
69
  const rules = rulesLine(cwd);
49
70
  if (rules) lines.unshift(rules);
50
71
  if (lines.length) process.stdout.write(lines.join('\n') + '\n');
51
- } catch (_) { /* never fail a session start */ }
72
+ }
73
+ main().catch(() => { /* never fail a session start */ });
@@ -47,10 +47,13 @@ function accountName(entry) {
47
47
  function parseView(stdout, error) {
48
48
  let doc = null;
49
49
  try { doc = JSON.parse(String(stdout || '').trim()); } catch (_) { /* judged below */ }
50
+ // npm 12 wraps a single-version answer in an array; ambiguous answers stay rejected.
51
+ if (Array.isArray(doc) && doc.length === 1) doc = doc[0];
50
52
  if (doc && typeof doc === 'object' && doc.error) {
51
53
  if (doc.error.code === 'E404') return { published: false };
52
54
  return { checkError: `${doc.error.code || 'npm error'}: ${oneLine(doc.error.summary || '')}`.trim() };
53
55
  }
56
+ if (error) return { checkError: oneLine(error.message) };
54
57
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
55
58
  return { checkError: error ? oneLine(error.message) : 'npm answered with something that is not the expected JSON' };
56
59
  }
@@ -117,16 +120,18 @@ function decide(state, trust, now = Date.now()) {
117
120
 
118
121
  const latest = typeof state.latest === 'string' && VERSION.test(state.latest) ? state.latest : null;
119
122
  if (state.published !== false && latest && state.installed && newer(latest, state.installed)) {
120
- const maintainers = Array.isArray(state.maintainers) ? state.maintainers.filter((m) => typeof m === 'string') : [];
123
+ const maintainers = Array.isArray(state.maintainers) ? state.maintainers : [];
121
124
  const trusted = new Set(trust);
122
125
  const untrusted = [...maintainers, ...(state.publisher ? [state.publisher] : [])].filter((m) => !trusted.has(m));
123
- if (!maintainers.length) {
126
+ if (!maintainers.length || !maintainers.every((m) => typeof m === 'string' && accountName(m) === m)
127
+ || typeof state.publisher !== 'string' || !state.publisher || accountName(state.publisher) !== state.publisher) {
124
128
  lines.push(`[passioncode] ${PACKAGE}@${latest} is on npm but who published it could not be read — not installing; see SECURITY.md.`);
125
129
  } else if (untrusted.length) {
126
130
  lines.push(`[passioncode] ${PACKAGE}@${latest} is on npm but published by ${[...new Set(untrusted)].join(', ')}, not a trusted PassionCode publisher — not installing; see SECURITY.md.`);
127
131
  } else {
128
132
  const auto = (state.config && state.config.auto) !== false;
129
- const running = state.updatingSince && now - Date.parse(state.updatingSince) < 10 * 60 * 1000;
133
+ const runningAge = now - Date.parse(state.updatingSince);
134
+ const running = Number.isFinite(runningAge) && runningAge >= 0 && runningAge < 10 * 60 * 1000;
130
135
  if (auto && !running) {
131
136
  spawn = ['--yes', `${PACKAGE}@${latest}`, 'update', '--quiet'];
132
137
  stateChanges.updatingSince = new Date(now).toISOString();
@@ -117,7 +117,7 @@ Also, before the first edit:
117
117
  - **Ids.** In a repository with `.claude/agent-sync.json`, reserve an id before writing
118
118
  it: `agent_sync.py reserve <REGISTER>` (for example `ADR` in fabric). Reading the
119
119
  "next free id" line is not reserving it — two agents read the same number.
120
- - **Leases.** `fabric` and `fabric-agent-contract` guard shared files (their
120
+ - **Leases.** Every enrolled repository guards its declared shared files (its
121
121
  `docs/AGENT_SYNC.md` lists them) under a git lease, a ref under
122
122
  `refs/agent-sync/leases/` on `origin`, so both machines see it. `acquire` → edit →
123
123
  `release`, on every path, failure included.
@@ -128,6 +128,17 @@ Also, before the first edit:
128
128
  agent-sync missing: `npx @ssheleg/agent-sync install`. If it cannot be installed, do
129
129
  not edit a guarded file — say which file and why you stopped.
130
130
 
131
+ ### The common backlog
132
+
133
+ The workspace's `knowledge/backlog.md` owns the aggregation contract. Each repository's
134
+ `docs/backlog-sources.json` declares the canonical local task sources and their vision goals.
135
+ Read those sources before choosing work. Update status only in the owning source under a lease,
136
+ keep stable IDs and closure receipts, and add a new source to the manifest in the same change.
137
+ A cross-repository task has one owner; other repositories link to it instead of copying status.
138
+ The common view at https://wiki.passioncode.ai/backlog is generated from these sources and their
139
+ commit identities. Never edit generated task status. Land the local change, publish through
140
+ Fabric's workspace sync, and check the published source commit before calling it current.
141
+
131
142
  ## 5. Secrets
132
143
 
133
144
  - Never in Git, a chat message, an issue, a command argument, a URL query string or a
@@ -202,8 +213,8 @@ to a public surface.
202
213
  `project-observatory full update --check` and `--apply`.
203
214
  - **Launcher** (`passioncode`): members are tagged first, `family.json` refs point at
204
215
  those tags, the payload is vendored from tags only.
205
- - **Contract pins.** `fabric-agent-contract` has no tags, so consumers pin commits, and
206
- the pins disagree (org-index `BACKLOG.md` X-4). Move a pin only in a release of the
216
+ - **Contract pins.** `fabric-agent-contract` consumers carry explicit commit pins;
217
+ compatibility work is tracked in the owning consumer backlog and the derived workspace view. Move a pin only in a release of the
207
218
  consumer, and only when its own validation passes against the new commit.
208
219
 
209
220
  Read [releasing](references/releasing.md) for the step-by-step of each repository's
@@ -58,7 +58,7 @@ The README section "Validate this repository" is the gate:
58
58
  ```bash
59
59
  python3 -m unittest discover -s test -v
60
60
  python3 test/validate.py
61
- node --test 'test/*.test.js'
61
+ node --test 'test/*.test.mjs'
62
62
  claude plugin validate ./plugins/fabric-agent-adapter --strict
63
63
  claude plugin validate . --strict
64
64
  ```
@@ -78,16 +78,17 @@ manifests. Trigger and scenario evals change before the instructional prose they
78
78
 
79
79
  ## Contract pins
80
80
 
81
- - `fabric-agent-contract` publishes no tags; consumers (fabric, the adapter, the
82
- Observatory) pin a commit. The pins disagree today — org-index `BACKLOG.md` X-4 tracks
83
- it.
81
+ - Consumers declare their own explicit contract pins. Read the actual lock or
82
+ submodule commit, then the owning consumer backlog and shared workspace view;
83
+ do not infer compatibility from matching version labels.
84
84
  - Moving a pin is a compatibility change of the **consumer**: run the consumer's own
85
85
  validation against the new contract commit (for the adapter:
86
86
  `adapt_project.py check <bundle> --contract <checkout at the new commit>` for each
87
87
  profile), review what changed between the two commits, and ship it as a new consumer
88
88
  release. If the validation does not pass, leave the pin and report why.
89
- - `project-observatory-contract` publishes immutable `rev-N` tags; a schema `$id` change
90
- needs a new `rev-N`.
89
+ - `project-observatory-contract` preserves historical immutable `rev-N` tags. Current
90
+ Project Observatory schemas ship with the engine release and its per-file release pins;
91
+ the engine publication checker refuses the retired `--stage` and `--publish` operations.
91
92
 
92
93
  ## After a release
93
94