@passioncode-ai/passioncode 0.1.14 → 0.1.17

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.
Files changed (26) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +22 -6
  3. package/SECURITY.md +24 -8
  4. package/bin/passioncode.js +32 -5
  5. package/family.json +1 -1
  6. package/lib/launcher.js +143 -39
  7. package/package.json +4 -4
  8. package/payload/.claude-plugin/marketplace.json +2 -2
  9. package/payload/manifest.json +8 -8
  10. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +92 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +86 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +92 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +86 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +13 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +92 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +86 -0
  21. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
  22. package/payload/plugins/passioncode/hooks/repo-rules.js +1 -1
  23. package/payload/plugins/passioncode/hooks/session-start.js +34 -12
  24. package/payload/plugins/passioncode/hooks/update-check.js +8 -3
  25. package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +14 -3
  26. package/payload/plugins/passioncode/skills/working-in-passioncode/references/releasing.md +7 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.17 - 2026-10-01
4
+
5
+ ### Changed
6
+
7
+ - Pin Fabric Agent Adapter v0.5.6 so the installed set carries its dashboard deep-link procedure and portable handoff checker. The Observatory Log and working-in-passioncode members keep their existing behavior.
8
+ - Dashboard routing evidence belongs to the [adapter handoff](https://github.com/passioncode-ai/fabric-agent-adapter/blob/v0.5.6/docs/handoffs/2026-10-01-dashboard-links.md); the launcher installs these bytes through its existing plugin and agents-hub channels.
9
+
10
+ ## 0.1.16 - 2026-10-01
11
+
12
+ ### Fixed
13
+
14
+ - The packed lifecycle release check accepts both npm 11’s array and npm 12’s
15
+ package-name-keyed JSON output, and rejects ambiguous or malformed pack results.
16
+ - The update probe accepts npm 12’s singleton metadata array while preserving
17
+ publisher checks and rejecting ambiguous or malformed responses.
18
+ - The 0.1.15 tag and GitHub release remain historical: its release workflow stopped
19
+ at this check before npm publication. Version 0.1.16 includes the fixes and shared
20
+ backlog instructions described below.
21
+
22
+ ## 0.1.15 - 2026-10-01
23
+
24
+ ### Fixed
25
+
26
+ - Restore and uninstall preserve recoverable state, reject unsupported options before changes,
27
+ and report failed filesystem or plugin operations truthfully. Updates reject inconsistent
28
+ payloads and stop dependent work after a failed release or marketplace step.
29
+ - Status reports the installed release correctly and retains concurrent probe/configuration
30
+ updates. Self-update checks reject untrusted or incomplete npm publisher metadata and
31
+ recover from failed spawns and future timestamps.
32
+ - Node 18 test discovery, release-tag validation and the packed lifecycle smoke now guard
33
+ the release path. The launcher audit records the regression evidence.
34
+
35
+ ### Changed
36
+
37
+ - The repository skill and SessionStart reminder direct agents to canonical local backlog
38
+ sources and the derived workspace backlog, with one owner per task and source-commit checks.
39
+ - Developer documentation now states the CLI, recovery and self-update contracts and checks
40
+ their local references. Tagged Fabric Agent Adapter 0.5.5 and Observatory Log 0.13.0 stay
41
+ in the family; this release does not add a new family member.
42
+
3
43
  ## 0.1.14 - 2026-10-01
4
44
 
5
45
  ### Changed
package/README.md CHANGED
@@ -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,6 +2,21 @@
2
2
  'use strict';
3
3
  const L = require('../lib/launcher');
4
4
 
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
+
5
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
@@ -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
@@ -8,7 +8,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.5",
11
+ "ref": "v0.5.6",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "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.14",
4
- "description": "The PassionCode.ai agent skill set — 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.17",
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"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.5.5",
14
+ "version": "0.5.6",
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",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.14",
38
+ "version": "0.1.17",
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",