@passioncode-ai/passioncode 0.1.26 → 0.1.28

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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.28 - 2026-10-05
4
+
5
+ The launcher meets the organization's
6
+ [product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md):
7
+ findings F13, F14, F15 and F17 of the 2026-10-03 lifecycle audit. Observatory Log moves to
8
+ Project Observatory 0.16.0.
9
+
10
+ ### Changed
11
+
12
+ - **Observatory Log is pinned at `v0.16.0`** (plugin 0.16.0; it was `v0.15.0`, plugin 0.15.0),
13
+ the Project Observatory 0.16.0 skills. The skills' text is unchanged and follows the release:
14
+ agent memory now checks an access binding on every call, embeds remotely only with the
15
+ operator's per-project consent, and can be erased with a receipt (`full forget`).
16
+
17
+ ### Fixed
18
+
19
+ - **One update at a time (LC-03, F14).** `update`, `restore` and `uninstall` take
20
+ `~/.passioncode/update.lock`; a second one refuses with exit 1 and names the holder, so a
21
+ background update and a manual one no longer interleave their `claude plugin` writes.
22
+ - **One `npm view` for sessions started together (F13).** The SessionStart hook claims
23
+ `~/.passioncode/probe.pending` before it starts the probe; the probe removes it after recording,
24
+ and a marker older than five minutes is taken over.
25
+ - **Releases no longer accumulate (LC-11, LC-15, F15).** An update keeps the release it installed
26
+ and the one it replaced, and removes older releases and partial copies left by a killed update
27
+ (`prune` step; a dry run plans it).
28
+ - **A release proves its provenance (F17).** `vendor --release` refuses a member whose tag does not
29
+ name the plugin version it carries. A member whose repository's tags version another product
30
+ may declare `refVersions: "repository"` with the plugin `version` it expects; the bytes at that
31
+ tag must carry that version, and the manifest records `refVersions` so the pair reads as the
32
+ repository's release, not the plugin's. No member declares it yet: at the current pins every tag
33
+ names its plugin version (Observatory Log `v0.16.0` carries 0.16.0), and whether Observatory Log
34
+ should use it when the two diverge again is the open operator decision PC-04.
35
+ - `AGENTS.md` gains a `## Lifecycle` section: the background footprint with the SessionStart
36
+ hook's cost and cadence (LC-09) and build retention (LC-15).
37
+
38
+ ## 0.1.27 - 2026-10-04
39
+
40
+ ### Changed
41
+
42
+ - **Fabric Agent Adapter is pinned at `v0.6.3`** (it was `v0.6.2`). `building-fabric-services`
43
+ and `creating-fabric-agents` say that every credential an agent or service uses comes from
44
+ Project Observatory, by name: `use_secret.py run --vault-only` for an agent, `use_secret.py
45
+ serve` for a long-running service, never a `.env` or a key file beside it.
46
+
3
47
  ## 0.1.26 - 2026-10-04
4
48
 
5
49
  ### Changed
package/README.md CHANGED
@@ -53,7 +53,8 @@ out of it, even when they implement Fabric protocols.
53
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
- - Each version is an immutable release under `~/.passioncode/releases/<version>`.
56
+ - Each version is an immutable release under `~/.passioncode/releases/<version>`; an update
57
+ keeps the release it replaced for rollback and removes older ones.
57
58
 
58
59
  ## Staying current
59
60
 
package/SECURITY.md CHANGED
@@ -16,7 +16,9 @@ deleted: a recovery record is written before the move in
16
16
  `~/.passioncode/quarantine/<run>/moved.json`. `restore` processes the latest batch,
17
17
  checkpoints completed entries and refuses to overwrite a conflicting user replacement.
18
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
19
+ automatic updates; it retains the current and previous release copies and recovery
20
+ records. Each update removes the launcher's own older releases (its output, reinstallable
21
+ from npm) and keeps the one it replaced for rollback. A failed removal
20
22
  returns a nonzero exit code. See the [CLI recovery contract](docs/reference/cli.md).
21
23
 
22
24
  ## The self-update and who it trusts
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.6.2",
11
+ "ref": "v0.6.3",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
@@ -22,7 +22,7 @@
22
22
  "name": "observatory-log",
23
23
  "displayName": "Observatory Log",
24
24
  "repo": "passioncode-ai/project-observatory-dashboard",
25
- "ref": "v0.15.0",
25
+ "ref": "v0.16.0",
26
26
  "kind": "plugin",
27
27
  "path": "observatory/engine/skill/plugins/observatory-log",
28
28
  "legacyPluginIds": [
package/lib/launcher.js CHANGED
@@ -67,6 +67,94 @@ function symlinkAtomic(target, at) {
67
67
  fs.renameSync(tmp, at);
68
68
  }
69
69
 
70
+ // --- one update at a time (lifecycle contract LC-03) ---------------------------------------
71
+ // update, restore and uninstall all write installed_plugins.json, the hub and state.json. A
72
+ // background update (started by the session hook) and a manual one used to interleave their
73
+ // `claude plugin` writes. They now take one lock; the second refuses and names the holder.
74
+ const O_EXLOCK = 0x20; // BSD/macOS open(2) flag: take flock(LOCK_EX); absent from fs.constants
75
+
76
+ class UpdateRunning extends Error {
77
+ constructor(pid, file) {
78
+ super(`Another PassionCode update is already running (${pid ? `process ${pid}` : 'another process'} holds ${file}); let it finish, then retry.`);
79
+ this.pid = pid;
80
+ }
81
+ }
82
+
83
+ function readPid(file) {
84
+ try { const text = fs.readFileSync(file, 'utf8').trim(); return /^\d{1,10}$/.test(text) ? Number(text) : null; } catch (_) { return null; }
85
+ }
86
+
87
+ function alive(pid) {
88
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
89
+ }
90
+
91
+ /**
92
+ * Take ~/.passioncode/update.lock. On macOS it is a kernel flock (released when the holder dies,
93
+ * so it is never stale); elsewhere an exclusively created pid file whose holder must be alive.
94
+ * Returns `{ release() }`; throws UpdateRunning when another process holds it.
95
+ */
96
+ function acquireUpdateLock(p) {
97
+ fs.mkdirSync(p.state, { recursive: true });
98
+ const file = path.join(p.state, 'update.lock');
99
+ if (process.platform === 'darwin') {
100
+ let fd;
101
+ try {
102
+ fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK, 0o600);
103
+ } catch (error) {
104
+ if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new UpdateRunning(readPid(file), file);
105
+ throw error;
106
+ }
107
+ fs.ftruncateSync(fd, 0);
108
+ fs.writeSync(fd, String(process.pid));
109
+ return { release() { if (fd !== null) { fs.closeSync(fd); fd = null; } } };
110
+ }
111
+ for (let attempt = 0; attempt < 2; attempt += 1) {
112
+ try {
113
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx', mode: 0o600 });
114
+ return { release() { if (readPid(file) === process.pid) fs.rmSync(file, { force: true }); } };
115
+ } catch (error) {
116
+ if (error.code !== 'EEXIST') throw error;
117
+ const pid = readPid(file);
118
+ if (pid && pid !== process.pid && alive(pid)) throw new UpdateRunning(pid, file);
119
+ // The holder is gone: move its file aside atomically, so two takers cannot both steal it.
120
+ try { fs.renameSync(file, `${file}.stale.${process.pid}`); fs.rmSync(`${file}.stale.${process.pid}`, { force: true }); } catch (_) { /* another taker moved it */ }
121
+ }
122
+ }
123
+ throw new UpdateRunning(readPid(file), file);
124
+ }
125
+
126
+ function withUpdateLock(home, fn) {
127
+ const lock = acquireUpdateLock(paths(home));
128
+ try { return fn(); } finally { lock.release(); }
129
+ }
130
+
131
+ // --- release retention (LC-11, LC-15) -----------------------------------------------------------
132
+ const semver = (v) => v.split('.').map(Number);
133
+ function newerVersion(a, b) {
134
+ const pa = semver(a), pb = semver(b);
135
+ for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] > pb[i];
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * The release directories an update may remove: every one except the release it installed and
141
+ * the one it replaced (the rollback; the newest other release when it replaced none), plus any
142
+ * `*.partial` copy a killed update left behind (safe: the caller holds the update lock).
143
+ * Files, symlinks and anything not named like a release are never touched.
144
+ */
145
+ function staleReleases(p, version, previous) {
146
+ let entries;
147
+ try { entries = fs.readdirSync(p.releases, { withFileTypes: true }); } catch (_) { return []; }
148
+ const dirs = entries.filter((e) => e.isDirectory() && !e.isSymbolicLink()).map((e) => e.name);
149
+ const partial = dirs.filter((n) => /^\d+\.\d+\.\d+\.\d+\.partial$/.test(n));
150
+ const versions = dirs.filter((n) => /^\d+\.\d+\.\d+$/.test(n));
151
+ let keep = previous && versions.includes(previous) && previous !== version ? previous : null;
152
+ if (!keep) {
153
+ for (const v of versions) if (v !== version && (!keep || newerVersion(v, keep))) keep = v;
154
+ }
155
+ return [...versions.filter((v) => v !== version && v !== keep), ...partial].map((n) => path.join(p.releases, n));
156
+ }
157
+
70
158
  /** Claude Code's plugin CLI, overridable for tests. */
71
159
  function claudeRunner() {
72
160
  const bin = process.env.PASSIONCODE_CLAUDE || 'claude';
@@ -303,6 +391,16 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
303
391
  }
304
392
  }
305
393
 
394
+ // 5. The launcher's own releases: keep this one and the one it replaced, remove the rest.
395
+ // They are its own output, reinstallable from npm — not the person's files, which are
396
+ // only ever moved to the quarantine.
397
+ const stale = staleReleases(p, version, state.installed || null);
398
+ if (stale.length) {
399
+ step('prune', `remove ${stale.map((d) => path.basename(d)).join(', ')} from ${p.releases}; keep ${version} and the release before it`, () => {
400
+ for (const dir of stale) fs.rmSync(dir, { recursive: true, force: true });
401
+ });
402
+ }
403
+
306
404
  if (!dryRun) {
307
405
  if (moved.length) writeJson(path.join(quarantineDir, 'moved.json'), moved);
308
406
  // A probe or config command may have written while the plugin CLI ran.
@@ -472,4 +570,15 @@ function uninstall({ home, claude = claudeRunner() } = {}) {
472
570
  return removed;
473
571
  }
474
572
 
475
- module.exports = { update, restore, status, setConfig, uninstall, paths, CHANNELS, MARKETPLACE, payloadManifest, marketplaceAddArg };
573
+ module.exports = {
574
+ // An invalid payload is refused before the lock creates anything under ~/.passioncode.
575
+ update: (opts = {}) => {
576
+ if (opts.dryRun) return update(opts);
577
+ verifyPayload(PAYLOAD(), payloadManifest());
578
+ return withUpdateLock(opts.home, () => update(opts));
579
+ },
580
+ restore: (opts = {}) => withUpdateLock(opts.home, () => restore(opts)),
581
+ uninstall: (opts = {}) => withUpdateLock(opts.home, () => uninstall(opts)),
582
+ status, setConfig, paths, CHANNELS, MARKETPLACE, payloadManifest, marketplaceAddArg,
583
+ acquireUpdateLock, UpdateRunning, staleReleases,
584
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
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"
@@ -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.6.2",
14
+ "version": "0.6.3",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -23,7 +23,7 @@
23
23
  "name": "observatory-log",
24
24
  "displayName": "Observatory Log",
25
25
  "source": "./plugins/observatory-log",
26
- "version": "0.15.0",
26
+ "version": "0.16.0",
27
27
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
28
28
  "author": {
29
29
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.26",
38
+ "version": "0.1.28",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.6.2",
11
- "commit": "4b435baac85e343b530af86d56f8b75ac406bc43",
12
- "version": "0.6.2",
10
+ "ref": "v0.6.3",
11
+ "commit": "30a1148ba77fe9ed56ea98c218d10ccf2fadf084",
12
+ "version": "0.6.3",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:95fab0fdf3ee1f9171fc7c07edd60313cf52ddb68bb2eb456caf1df8afde36cb",
25
+ "contentHash": "sha256:264191fab8632a4f720185c68f79ca74c8fba1a14e50fd2d68957a35303c9c4b",
26
26
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -34,9 +34,9 @@
34
34
  "name": "observatory-log",
35
35
  "displayName": "Observatory Log",
36
36
  "repo": "passioncode-ai/project-observatory-dashboard",
37
- "ref": "v0.15.0",
38
- "commit": "acca5c9d293a388cd53d3a2cbdbfabf574308bb4",
39
- "version": "0.15.0",
37
+ "ref": "v0.16.0",
38
+ "commit": "aa6788d51e248de54c3f7184b17206af43e0b28e",
39
+ "version": "0.16.0",
40
40
  "via": "clone of https://***@github.com/passioncode-ai/project-observatory-dashboard.git",
41
41
  "skills": [
42
42
  "explaining-changes",
@@ -49,7 +49,7 @@
49
49
  "legacyMarketplaces": [
50
50
  "observatory-log"
51
51
  ],
52
- "contentHash": "sha256:523f50d6be43845f0ed2b5d497bac17752cbfd3333627a235ab457618cd86dd3",
52
+ "contentHash": "sha256:f0bfd0f79ee99d25e6c82ad5f84337de2ce09f406fb4f5036cb45e3aa20d490a",
53
53
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
54
54
  "author": {
55
55
  "name": "PassionCode.ai",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.26",
64
+ "ref": "v0.1.28",
65
65
  "commit": null,
66
- "version": "0.1.26",
66
+ "version": "0.1.28",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:01035ebeb36b330a3eabad618a5dbd6f8b826b0d008ffc3651db1c9dca932deb",
73
+ "contentHash": "sha256:c071f7a058bd6a8b8135ca7dd498f0b72ecd31cff1086ebbd3567611d66dbc98",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.6.2",
6
+ "version": "0.6.3",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.6.2"
8
+ version: "0.6.3"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
11
11
  ---
@@ -15,7 +15,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
15
15
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
16
16
  metadata:
17
17
  author: PassionCode.ai
18
- version: "0.6.2"
18
+ version: "0.6.3"
19
19
  contract-version: "0.1.0"
20
20
  extension: "fabric-service/0.1"
21
21
  extension-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
@@ -101,8 +101,12 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
101
101
  digest and the process start time. *(A service ran on stale code after an edit and
102
102
  burned budget; nothing showed which build was answering.)*
103
103
  5. **State outside code.** Data and config in `~/Library/Application Support/<id>/`,
104
- logs in `~/Library/Logs/<id>/`, cache in `~/Library/Caches/<id>/`; secrets in a 0600
105
- file. Never inside the service's own code checkout or a release directory; a
104
+ logs in `~/Library/Logs/<id>/`, cache in `~/Library/Caches/<id>/`; the service's own
105
+ access token in a 0600 file it generates (principle 6). **Every provider credential the
106
+ service uses comes from Project Observatory, by name** — the plist starts the service
107
+ with `use_secret.py serve --consumer <label> <project> <NAME>[,<NAME>] -- <command>`, which
108
+ reads the vault only and records the consumer so a rotation names it; never a copy of a
109
+ key in a `.env`, a config file or the plist. Never inside the service's own code checkout or a release directory; a
106
110
  repository that versions the data itself is a store and is fine — declare
107
111
  `source.repository` so the probe can tell them apart. *(Deleting a checkout left a
108
112
  plist launchd retried every 10 s, and the data went with it.)*
@@ -254,7 +258,10 @@ things change and the kits implement each:
254
258
  example is [`scripts/sample-remote-service.mjs`](scripts/sample-remote-service.mjs): it runs
255
259
  with its own TLS or behind a platform router.
256
260
  2. **Secrets live on the platform:** the service token and the session key (32 bytes) are
257
- platform secrets, never in the repository, a URL or an argument vector.
261
+ platform secrets, never in the repository, a URL or an argument vector. A provider key the
262
+ remote service needs is issued and kept in Project Observatory, pushed to the platform, and
263
+ the movement recorded with `vault.py moved --at <provider>`, so the platform's copy is
264
+ known rather than invisible.
258
265
  3. **Register it on the operator's computer** — the installer side, run there:
259
266
  `registerRemote({ id, name, origin, token })` / `register_remote(...)` writes the token file
260
267
  (0600) and the descriptor; pass the token from a file or stdin, never in argv. Public names
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.6.2"
8
+ version: "0.6.3"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
11
11
  ---
@@ -94,6 +94,13 @@ first commit:
94
94
  - an `AGENTS.md` that opens with the template's *Read first* block and closes with *After work*,
95
95
  and a `CLAUDE.md` whose first line is `@AGENTS.md`.
96
96
 
97
+ **Every credential the agent uses comes from Project Observatory, by name**, from its first
98
+ commit: the README's Configure section lists key NAMES; the agent finds them with
99
+ `observatory_credentials` and runs with `use_secret.py run --vault-only` (a long-running
100
+ service: `use_secret.py serve`), with `OBSERVATORY_VAULT_ONLY=1` in its environment. No `.env`
101
+ of its own, no key file beside its data, no value in code, configuration or a commit. A
102
+ workflow names the keys it needs in its checkpoint (`credentials`), never their values.
103
+
97
104
  org-index `scripts/check_format.py` reports 0 findings for it before it is called done. **An agent
98
105
  someone builds for themselves is not a PassionCode.ai repository:** its licence is its owner's
99
106
  choice, nothing about it is published or listed by the organization, and none of these files is
@@ -3,7 +3,7 @@
3
3
  "name": "observatory-log",
4
4
  "displayName": "Observatory Log",
5
5
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
6
- "version": "0.15.0",
6
+ "version": "0.16.0",
7
7
  "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
8
8
  "author": {
9
9
  "name": "PassionCode.ai",
@@ -15,7 +15,7 @@ description: >-
15
15
  decision with different evidence.
16
16
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
17
17
  metadata:
18
- version: "0.15.0"
18
+ version: "0.16.0"
19
19
  compatibility: >-
20
20
  Requires an initialized full Project Observatory installation to persist
21
21
  records. Uses its MCP server when available in the current host; the bundled
@@ -10,7 +10,7 @@ description: >-
10
10
  provider permissions or choosing a project's authentication architecture.
11
11
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
12
12
  metadata:
13
- version: "0.15.0"
13
+ version: "0.16.0"
14
14
  compatibility: >-
15
15
  Requires an initialized full Project Observatory installation, Python 3.11+
16
16
  and local shell access on macOS or Linux. Provider operations additionally
@@ -30,7 +30,7 @@ names. Never ask the user to paste a credential into the conversation.
30
30
  2. Select the user's initialized `OBSERVATORY_HOME`. Run
31
31
  `project-observatory full doctor`. A missing workspace
32
32
  needs the documented onboarding before secret operations.
33
- 3. Run `"$OBSERVATORY_PYTHON" "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.15.0`
33
+ 3. Run `"$OBSERVATORY_PYTHON" "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.16.0`
34
34
  (`full agent install` records that interpreter; the stock macOS `python3` is too old).
35
35
  If stale, read the installed skill once and follow its compatible commands.
36
36
  Do not turn an unavailable version check into a retry loop.
@@ -15,7 +15,7 @@ description: >-
15
15
  the repository.
16
16
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
17
17
  metadata:
18
- version: "0.15.0"
18
+ version: "0.16.0"
19
19
  compatibility: >-
20
20
  Requires a full Project Observatory installation with organizations.json
21
21
  configured; the observatory MCP server or its local CLI. Creating the resource
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.1.26",
5
+ "version": "0.1.28",
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",
@@ -6,11 +6,15 @@ const { execFile } = require('child_process');
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
- const { PACKAGE, parseView, recordProbe } = require('./update-check');
9
+ const { PACKAGE, parseView, recordProbe, releaseProbe } = require('./update-check');
10
10
 
11
11
  const DIR = process.env.PASSIONCODE_HOME || path.join(os.homedir(), '.passioncode');
12
12
  const STATE = path.join(DIR, 'state.json');
13
13
  execFile('npm', ['view', PACKAGE, 'version', 'maintainers', '_npmUser', '--json'], { timeout: 30000 }, (error, stdout) => {
14
+ try { record(error, stdout); } finally { releaseProbe(DIR); } // the hook claimed probe.pending before it started us
15
+ });
16
+
17
+ function record(error, stdout) {
14
18
  let state = {};
15
19
  try { state = JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch (_) { /* first run */ }
16
20
  const result = parseView(stdout, error);
@@ -20,4 +24,4 @@ execFile('npm', ['view', PACKAGE, 'version', 'maintainers', '_npmUser', '--json'
20
24
  fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
21
25
  fs.renameSync(tmp, STATE);
22
26
  process.stdout.write(`${next.checkedAt} ${result.checkError ? `check failed: ${result.checkError}` : result.published ? `latest ${result.latest} by ${result.maintainers.join(', ') || '(unreadable maintainers)'}` : `${PACKAGE} is not published`}\n`);
23
- });
27
+ }
@@ -9,7 +9,7 @@ const { spawn } = require('child_process');
9
9
  const fs = require('fs');
10
10
  const os = require('os');
11
11
  const path = require('path');
12
- const { decide, loadTrust } = require('./update-check');
12
+ const { decide, loadTrust, claimProbe, releaseProbe } = require('./update-check');
13
13
  const { rulesLine } = require('./repo-rules');
14
14
 
15
15
  const DIR = process.env.PASSIONCODE_HOME || path.join(os.homedir(), '.passioncode');
@@ -38,8 +38,9 @@ async function main() {
38
38
  const state = read(STATE, {});
39
39
  const now = Date.now();
40
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');
41
+ // The marker is claimed before the spawn: sessions restored together start one probe.
42
+ if ((!Number.isFinite(age) || age < 0 || age > DAY) && claimProbe(DIR, now)) {
43
+ if (!await detached(process.execPath, [path.join(__dirname, 'probe.js')], 'probe.log')) releaseProbe(DIR);
43
44
  }
44
45
  const { lines, spawn: args, stateChanges } = decide(state, loadTrust(), now);
45
46
  if (Object.keys(stateChanges).length) {
@@ -102,6 +102,38 @@ function newer(a, b) {
102
102
  return false;
103
103
  }
104
104
 
105
+ /**
106
+ * One probe at a time. The probe writes `checkedAt` only when npm answers (up to 30 s), so every
107
+ * session that starts meanwhile saw a stale check and started its own `npm view`. The hook now
108
+ * claims `probe.pending` with an exclusive create BEFORE it spawns; the probe removes it after it
109
+ * has recorded. A marker older than PROBE_STALE_MS (or dated in the future) belongs to a probe
110
+ * that died: it is moved aside atomically — so two hooks cannot both take it over — and claimed.
111
+ */
112
+ const PROBE_MARKER = 'probe.pending';
113
+ const PROBE_STALE_MS = 5 * 60 * 1000;
114
+
115
+ function claimProbe(dir, now = Date.now()) {
116
+ const file = path.join(dir, PROBE_MARKER);
117
+ const create = () => { fs.writeFileSync(file, new Date(now).toISOString(), { flag: 'wx', mode: 0o600 }); return true; };
118
+ try {
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ return create();
121
+ } catch (error) {
122
+ if (error.code !== 'EEXIST') return false;
123
+ }
124
+ let age;
125
+ try { age = now - fs.statSync(file).mtimeMs; } catch (_) { return false; } // just released: the next session checks
126
+ if (age >= 0 && age < PROBE_STALE_MS) return false;
127
+ const aside = `${file}.stale.${process.pid}`;
128
+ try { fs.renameSync(file, aside); } catch (_) { return false; } // another hook took it over
129
+ fs.rmSync(aside, { force: true });
130
+ try { return create(); } catch (_) { return false; }
131
+ }
132
+
133
+ function releaseProbe(dir) {
134
+ fs.rmSync(path.join(dir, PROBE_MARKER), { force: true });
135
+ }
136
+
105
137
  function oneLine(text) { return String(text).replace(/\s+/g, ' ').trim().slice(0, 200); }
106
138
 
107
139
  /**
@@ -144,4 +176,4 @@ function decide(state, trust, now = Date.now()) {
144
176
  return { lines, spawn, stateChanges };
145
177
  }
146
178
 
147
- module.exports = { PACKAGE, OIDC_PUBLISHER, TRUST_FILE, accountName, parseView, recordProbe, loadTrust, newer, decide };
179
+ module.exports = { PACKAGE, OIDC_PUBLISHER, TRUST_FILE, PROBE_MARKER, PROBE_STALE_MS, accountName, parseView, recordProbe, loadTrust, newer, decide, claimProbe, releaseProbe };