@passioncode-ai/passioncode 0.1.14 → 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,38 @@
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
+
3
36
  ## 0.1.14 - 2026-10-01
4
37
 
5
38
  ### 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/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.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"
@@ -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.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,6 +1,6 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.14",
64
+ "ref": "v0.1.16",
65
65
  "commit": null,
66
- "version": "0.1.14",
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:a09752a2968ea3bd192898fe28642166548786a6396050f57e02f36ea9fe2742",
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",
@@ -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.14",
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