@passioncode-ai/passioncode 0.1.41 → 0.1.43

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 (18) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +13 -5
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +9 -0
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +70 -19
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +67 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +23 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +12 -6
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +782 -25
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +843 -15
  17. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  18. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.43 - 2026-10-12
4
+
5
+ ### Changed
6
+
7
+ - **Fabric Agent Adapter is pinned at `v0.19.0`** (it was `v0.18.0`). Fabric Agent Adapter 0.19.0 (FAA-13): both service kits keep settings backups of what the operator decided — outside the data directory, byte-identical between the kits, restored only into a fresh database and never over a row — list every spending limit a service applies in its usage report, and send the service token in the header the descriptor declares.
8
+
9
+ ## 0.1.42 - 2026-10-12
10
+
11
+ ### Changed
12
+
13
+ - **Fabric Agent Adapter is pinned at `v0.18.0`** (it was `v0.17.0`). Fabric Agent Adapter 0.18.0 (FAA-21): both service kits write and install the Linux and Windows supervisors — a systemd user unit and a per-user Scheduled Task, byte-identical between the kits, never carrying a secret, an upgrade restarting what runs while the operator's off stays off. The Node kit runs on Windows: the folders, descriptor validation at parity with Python, the token file's protected ACL and a named-pipe instance lock; the two kits now exclude each other on every system and the conformance probe sees a Node service's lock.
14
+
3
15
  ## 0.1.41 - 2026-10-12
4
16
 
5
17
  ### Changed
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.17.0",
11
+ "ref": "v0.19.0",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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.17.0",
14
+ "version": "0.19.0",
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.41",
38
+ "version": "0.1.43",
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.41",
3
+ "version": "0.1.43",
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.17.0",
11
- "commit": "b943ea2db8b20aa875ffe54f6d6129319e65cbe2",
12
- "version": "0.17.0",
10
+ "ref": "v0.19.0",
11
+ "commit": "71e230ea8b2f4bc8692c01b72e6b0310e165080d",
12
+ "version": "0.19.0",
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:14a3bfd965a38a0369c61f233ad2eca628bdc1b73b92aedf5d0b6cee1b6bd545",
25
+ "contentHash": "sha256:32a736b99e38a61e48316eaa240702bc0051c327e9a3fd8f455025853a851d2c",
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",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.41",
64
+ "ref": "v0.1.43",
65
65
  "commit": null,
66
- "version": "0.1.41",
66
+ "version": "0.1.43",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:acfd250d8e14df88268501d8d23bea2df13a5245a16ff2f9e573d92129d0cd24",
73
+ "contentHash": "sha256:9c3906e6e474edcd4ee277da795ab5f7c65049c634b4865e18cf40b531e4c1e0",
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.17.0",
6
+ "version": "0.19.0",
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.17.0"
8
+ version: "0.19.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---
@@ -13,10 +13,10 @@ description: >-
13
13
  kits, a TLS sample, live and layout probes. NOT for a one-off script or cron job, a hosted product with no agent
14
14
  behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
15
15
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
- 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
+ compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.17.0"
19
+ version: "0.19.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
@@ -190,10 +190,14 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
190
190
  4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
191
191
  and the one before it.
192
192
 
193
+ On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
194
+ Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
195
+ writers (`systemdUnit`, `taskXml`, …) and both kits write identical files.
196
+
193
197
  Code runs from an immutable release directory; an upgrade writes a new release,
194
198
  rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
195
199
  for the plist fields, the release layout, log rotation, behaving under a host lifecycle broker
196
- and the Linux case.
200
+ and the Linux and Windows supervisors.
197
201
 
198
202
  ## Step 5 — the dashboard and the events
199
203
 
@@ -224,8 +228,12 @@ log, retention and notification policy.
224
228
 
225
229
  An agent that calls paid models reports **what it spent**: one receipt per call from the
226
230
  provider's own usage numbers, served behind the token at `surfaces.usage` as the DEC-0021 usage
227
- report. An unknown cost is null, never 0. Read [the usage reference](references/usage.md)
228
- before wiring it.
231
+ report. An unknown cost is null, never 0. It lists **every** spending limit the service applies
232
+ in `budgets`, enforced or not. Read [the usage reference](references/usage.md) before wiring it.
233
+
234
+ A service whose store holds what the operator decided keeps **settings backups** outside its data
235
+ directory, restores only on a fresh database and never overwrites. Read
236
+ [the settings backups reference](references/settings-backups.md).
229
237
 
230
238
  ## Step 6 — verify
231
239
 
@@ -11,6 +11,15 @@
11
11
 
12
12
  The feed is a view: never copy rows into a second store that can drift from the first.
13
13
 
14
+ ## Reading a feed (FAC-SEM-036)
15
+
16
+ A client of `GET /fabric/v1/events` — or of any token-protected route — sends the token in the
17
+ header the descriptor's `auth.header` names, in the form `auth.scheme` names: the raw token for
18
+ `none`, `Bearer <token>` for `Bearer`. Never assume `Authorization: Bearer`: a client that did
19
+ was refused on every poll and silently dropped every notify event. `token_header(auth, token)` /
20
+ `tokenHeader(auth, token)` gives the one header; the token never travels in another header or
21
+ in the URL.
22
+
14
23
  ## Writing an event
15
24
 
16
25
  - `kind`: dotted, stable, past tense or state (`job.failed`, `job.awaiting_choice`,
@@ -8,23 +8,23 @@ wins and this page is the defect. The rule each section serves:
8
8
  | Contract rule | Here | Kit (Python / Node) |
9
9
  |---|---|---|
10
10
  | LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
11
- | LC-03 a job is bounded and exclusive | [one copy under launchd](#one-copy-under-launchd) | `hold_single_instance` / `holdSingleInstance` |
12
- | LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
11
+ | LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
12
+ | LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
13
13
  | LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
14
14
  | LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
15
- | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
15
+ | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
16
16
  | LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
17
17
 
18
18
  ## Contents
19
19
 
20
20
  - [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
21
21
  - [Install, upgrade, uninstall](#install-upgrade-uninstall)
22
- - [One copy under launchd](#one-copy-under-launchd)
22
+ - [One copy under a supervisor](#one-copy-under-a-supervisor)
23
23
  - [Directories](#directories)
24
24
  - [Logs](#logs)
25
25
  - [Shutdown](#shutdown)
26
26
  - [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
27
- - [Linux](#linux)
27
+ - [Linux and Windows](#linux-and-windows)
28
28
 
29
29
  ## launchd plist (generate it with `launchd_plist`)
30
30
 
@@ -70,24 +70,25 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
70
70
  off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
71
71
  operator's real jobs.
72
72
 
73
- ## One copy under launchd
73
+ ## One copy under a supervisor
74
74
 
75
75
  Started by hand, a copy that finds `service.lock` held prints one sentence naming the
76
76
  holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
77
- (10 s), forever. So a copy whose plist carries `FABRIC_SERVICE_SUPERVISOR=launchd` backs
78
- off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
77
+ (10 s), forever, and under systemd one every `RestartSec`. So a copy whose plist or unit
78
+ carries `FABRIC_SERVICE_SUPERVISOR=launchd` (or `systemd`) backs off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
79
79
  and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
80
80
  nothing while it waits. Two installs sharing one data directory are still the defect to
81
81
  remove; the back-off only stops the loop from costing the machine.
82
82
 
83
83
  ## Directories
84
84
 
85
- | What | macOS | Linux |
86
- |---|---|---|
87
- | data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` |
88
- | logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` |
89
- | cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` |
90
- | code | `~/.local/share/<id>/releases/…` | same |
85
+ | What | macOS | Linux | Windows (DEC-0032) |
86
+ |---|---|---|---|
87
+ | data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` | `%LOCALAPPDATA%\<id>\` |
88
+ | logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` | `%LOCALAPPDATA%\<id>\Logs\` |
89
+ | cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` | `%LOCALAPPDATA%\<id>\Cache\` |
90
+ | code | `~/.local/share/<id>/releases/…` | same | under `%LOCALAPPDATA%` too, never a network share |
91
+ | settings backups ([DEC-0025](settings-backups.md)) | `~/Library/Application Support/PassionCode/settings-backups/<id>/<instance>/` | `${XDG_CONFIG_HOME:-~/.config}/PassionCode/settings-backups/…` | `%APPDATA%\PassionCode\settings-backups\…` |
91
92
 
92
93
  A repository that versions the data itself (a registry, a plan) may hold the data; the
93
94
  service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
@@ -140,8 +141,58 @@ has to survive being driven from outside:
140
141
  well-known document for a service, a process-level answer for a plain app — and keep it honest
141
142
  while the app is still starting.
142
143
 
143
- ## Linux
144
-
145
- Until a systemd adapter exists, declare `lifecycle.manager: "none"`. A `systemd --user`
146
- unit with `Restart=always` gives the same guarantees; the host will control it once the
147
- adapter ships. Everything else in this skill applies unchanged.
144
+ ## Linux and Windows
145
+
146
+ Both kits write and install the supervisor file the contract names (DEC-0032, service.md
147
+ *Supervisor*); the two kits write byte-identical files. A supervisor file never carries a
148
+ secret: the writers refuse a credential-shaped argument, environment name or value, and any
149
+ control character (a line break would end a unit line early).
150
+
151
+ **Linux — a systemd user unit.** `systemd_unit(unit, argv, working_directory=…)` /
152
+ `systemdUnit(unit, argv, {workingDirectory})` writes `Restart=always`, `RestartSec=10`,
153
+ `TimeoutStopSec` above the drain (15 s default), `KillMode=mixed`, `WantedBy=default.target`
154
+ and `FABRIC_SERVICE_SUPERVISOR=systemd`, quoting every argument (`%` → `%%`, `$` → `$$`).
155
+ `WorkingDirectory=` is written unquoted: systemd reads it as the rest of the line, so quotes would
156
+ become part of the path and the unit would be refused (`systemd-analyze verify` checks it in CI). The
157
+ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.service"}`.
158
+
159
+ | Verb | Command |
160
+ |---|---|
161
+ | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
162
+ | stop, keep off | `systemctl --user disable --now <unit>` |
163
+ | uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
164
+
165
+ A user unit runs while the person is logged in; one that must run without a session needs
166
+ `loginctl enable-linger <user>`, which is the operator's decision, not the installer's.
167
+
168
+ **Windows — a per-user Scheduled Task.** `task_xml(task, argv, working_directory=…)` /
169
+ `taskXml(task, argv, {workingDirectory})` writes a task with a logon trigger for the current
170
+ user, `InteractiveToken` and `LeastPrivilege` (no administrator rights), restart on failure
171
+ every minute, no execution time limit and `MultipleInstancesPolicy` `IgnoreNew`, its settings in the
172
+ order `schtasks` exports them; the arguments are quoted the way the C runtime parses them. The Windows
173
+ CI job has the real `schtasks` create, read back and delete such a task. The descriptor says
174
+ `"lifecycle": {"manager": "task-scheduler", "task": "\\PassionCode\\<id>.<instance>"}`.
175
+
176
+ | Verb | Command |
177
+ |---|---|
178
+ | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
179
+ | stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
180
+ | uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
181
+
182
+ A task cannot set an environment variable, so a copy Task Scheduler restarts does not back off
183
+ in-process; `IgnoreNew` keeps the scheduler from starting a second copy, and a hand-started copy
184
+ holding the lock makes the scheduled one exit 75 once a minute until it leaves.
185
+
186
+ **One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
187
+ (Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
188
+ a named pipe on Windows (`lock_name` / `lockName`), each gone with the process. The Python kit
189
+ holds the same name beside its file lock, so the two kits exclude each other everywhere, and
190
+ `check_service.py` reports the lock held when either is. The probe only looks (`/proc/net/unix`,
191
+ `WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail.
192
+
193
+ **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
194
+ token files*): the kits write a protected list — the user, SYSTEM and Administrators, set before the
195
+ token's bytes go in, any entry already there dropped — and refuse
196
+ on read a link, a file outside the profile, another owner, or any other granted SID, naming the
197
+ SID. The Node kit reads the list with Windows PowerShell 5.1 (.NET, no modules) and writes it
198
+ with `icacls`; the path goes through the environment, never a command line.
@@ -31,7 +31,7 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
31
31
  | `name`, `summary` | ≤ 80 and ≤ 200 characters |
32
32
  | `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
33
33
  | `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
34
- | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). The Python kit and the probe accept all four; the Node kit still accepts only `launchd` and `none` (FAA-16) |
34
+ | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). Both kits and the probe accept all four; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`) |
35
35
  | `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
36
36
  | `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
37
37
  | `source.repository`, `fabricManifest` | optional |
@@ -0,0 +1,67 @@
1
+ # Settings backups — what the operator decided survives the data directory (DEC-0025)
2
+
3
+ Fabric Agent Contract DEC-0025, `docs/specification/service.md` → *Settings backup*, schema
4
+ `schemas/settings-backup.schema.json`, rule `FAC-SEM-035`. A service whose store holds what the operator
5
+ decided — which projects it serves, how each is bound, which secret slot each binding uses, their overrides —
6
+ SHOULD keep settings backups, so a lost, purged or wrongly migrated data directory costs the operator nothing.
7
+
8
+ ## Where
9
+
10
+ `<root>/settings-backups/<id>/<instance>/settings-<UTC %Y%m%dT%H%M%S%fZ>.json`, outside the data directory:
11
+
12
+ | System | `<root>` (`settings_backup_root` / `settingsBackupRoot`) |
13
+ |---|---|
14
+ | macOS | `~/Library/Application Support/PassionCode` |
15
+ | Windows | `%APPDATA%\PassionCode` |
16
+ | Linux | `$XDG_CONFIG_HOME/PassionCode`, default `~/.config/PassionCode` |
17
+
18
+ `settings_backup_dir(id, instance)` / `settingsBackupDir(id, instance)` names the folder. Directories are 0700
19
+ (on Windows a protected ACL), files 0600, writes atomic.
20
+
21
+ ## Take a snapshot
22
+
23
+ ```python
24
+ dirs = fs.settings_backup_dir(ID, INSTANCE)
25
+ if fs.backup_due(dirs): # none yet, or the newest is older than 24 hours
26
+ doc = fs.make_settings_backup(service_id=ID, instance=INSTANCE, version=VERSION, schema=SCHEMA_VERSION,
27
+ tables=allow_listed_tables(db), reason="start") # or daily, uninstall, manual
28
+ fs.write_settings_backup(doc, dirs) # None when unchanged; keeps the 14 newest
29
+ ```
30
+
31
+ ```js
32
+ const dir = k.settingsBackupDir(ID, INSTANCE);
33
+ if (k.backupDue(dir)) k.writeSettingsBackup(k.makeSettingsBackup({ id: ID, instance: INSTANCE, version, schema, tables, reason: 'daily' }), dir);
34
+ ```
35
+
36
+ - **Tables on an allow-list**, each `{"columns": [...], "rows": [[...], ...]}` of JSON scalars. A fraction is its
37
+ decimal string (`"0.25"`), binary data is never stored, and an integer stays within ±(2^53 − 1).
38
+ - **Secrets by name only.** `make_settings_backup` refuses a table that can only hold connections, tokens,
39
+ secrets, credentials, passwords, login codes or sessions, and a column whose name says it holds a credential
40
+ value (`access_token`, `client_secret`); `secret_name` and `token_slot` pass. The names cannot see values, so
41
+ back up what is on the allow-list, never everything except a deny-list.
42
+ - **When:** at start when `backup_due`, then daily; on uninstall a last one, read-only (open the store without
43
+ migrating or writing). An unchanged snapshot is not rewritten; a temporary file an interrupted write left is
44
+ removed first. A fresh, empty database writes none: it would hide the good snapshot before it is restored.
45
+ - Both kits write byte-identical files (`test/fabric-service-backups.test.mjs`, last case).
46
+
47
+ ## Read and restore
48
+
49
+ `read_settings_backup` / `readSettingsBackup` refuses a file whose checksum, counts or row widths disagree with
50
+ its tables, that carries a value with no canonical form, or whose `format` it does not know, naming why
51
+ (`FAC-SEM-035`). The checksum is SHA-256 of the settings canonical form of `tables`
52
+ (`settings_canonical_json` / `settingsCanonicalJson`: Python's `json.dumps(sort_keys=True,
53
+ ensure_ascii=False)`).
54
+
55
+ Restore **adds and never overwrites**:
56
+
57
+ ```python
58
+ path, doc, skipped = fs.newest_intact_backup(dirs) # the newest intact snapshot with content; damaged ones named
59
+ plan = fs.restore_plan(doc, {"projects": {"columns": [...], "key": ["id"], "required": ["id", "name"],
60
+ "existing": existing_keys("projects")}})
61
+ # plan["tables"]["projects"]: insert, present, droppedColumns, defaultedColumns, missingRequired, refused
62
+ ```
63
+
64
+ - **Automatic restore** only on a *fresh* database — no domain records **and** the service has never started —
65
+ so a restart never undoes a deletion. Insert `plan[...]["insert"]` and add every row the database refuses to
66
+ `refused` with its key and reason.
67
+ - **Manual restore** shows the plan first; applying it is the operator's act, never an agent's.
@@ -50,6 +50,29 @@ left out.
50
50
  **Budget.** Pass `budget` only if the service **enforces** that limit itself. The report states
51
51
  it, and the host only shows it.
52
52
 
53
+ **Every limit (DEC-0027).** Pass `budgets` to list every spending limit the service applies,
54
+ enforced or not, so the spend view shows all of them — the operator asked for that on 2026-10-07:
55
+
56
+ ```python
57
+ ledger.report(service_id=ID, budget={"period": "month", "limitUsd": 100}, budgets=[
58
+ {"id": "machine.monthly", "scope": "machine", "kind": "monthly", "period": "month", "limitUsd": 100, "enforced": True},
59
+ {"id": "project.demo.daily", "scope": "project", "subject": "demo", "kind": "daily", "windowSeconds": 86400,
60
+ "limitUsd": 5, "spentUsd": 0.31, "enforced": True},
61
+ {"id": "job.cap", "scope": "job", "kind": "per_job", "limitUsd": 2, "enforced": True},
62
+ ])
63
+ ```
64
+
65
+ - A machine limit with a `period` and no `spentUsd` is counted by the kit from the receipts, the
66
+ way `budget` is, so `budget` and its twin in `budgets` never disagree. A per-order limit
67
+ (`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend.
68
+ - `scope` is `machine`, `project`, `pool` or `job`; a `project` or `pool` limit names its
69
+ `subject`. A limit has at most one window — `period`, `windowSeconds` or `since` — and it agrees
70
+ with the kind (`daily`: the day or 86400 s; `monthly`: the month or 28–31 days; `velocity`: a
71
+ rolling window). `tripped: true` (work stopped by it now) needs `enforced: true`. An unknown
72
+ kind is shown generically, never refused.
73
+ - The kits refuse a report that breaks these (`usage_limit_problems` / `usageLimitProblems`,
74
+ `FAC-SEM-031`). A breach is not refused: the report shows it, and the host shows it first.
75
+
53
76
  Call `ledger.prune()` at start (or daily) to keep the file at the reported window (LC-12).
54
77
 
55
78
  `JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
@@ -162,10 +162,9 @@ class Probe:
162
162
  conn.close()
163
163
 
164
164
  def auth_headers(self) -> Dict[str, str]:
165
- auth = self.d.get("auth", {})
166
- header = auth.get("header", "Authorization")
167
- value = self.token or ""
168
- return {header: ("Bearer " + value) if auth.get("scheme", "Bearer") == "Bearer" else value}
165
+ # The header and form the descriptor declares, never an assumed `Authorization: Bearer` (FAC-SEM-036).
166
+ name, value = fs.token_header(self.d.get("auth"), self.token or "")
167
+ return {name: value}
169
168
 
170
169
  # descriptor ------------------------------------------------------------
171
170
  def descriptor_rules(self) -> None:
@@ -730,7 +729,7 @@ class Probe:
730
729
  self.add("lifecycle.instance-lock", "PASS", "held by the running service")
731
730
  else:
732
731
  msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
733
- self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
732
+ self.lock_free(lock)
734
733
  finally:
735
734
  os.close(fd)
736
735
  return
@@ -747,10 +746,17 @@ class Probe:
747
746
  self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
748
747
  else:
749
748
  fcntl.flock(fd, fcntl.LOCK_UN)
750
- self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
749
+ self.lock_free(lock)
751
750
  finally:
752
751
  os.close(fd)
753
752
 
753
+ def lock_free(self, lock: Path) -> None:
754
+ """The file lock is free; a Node kit service holds the lock's name instead (fs.lock_name)."""
755
+ if fs.lock_name_held(lock):
756
+ self.add("lifecycle.instance-lock", "PASS", "held by the running service (the lock's name, %s)" % ("a named pipe" if fs.WINDOWS else "an abstract socket"))
757
+ else:
758
+ self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
759
+
754
760
  def run(self) -> List[Result]:
755
761
  self.descriptor_rules()
756
762
  if not self.port: