@passioncode-ai/fabric-agent-adapter 0.61.0 → 0.63.0

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 (53) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +6 -3
  3. package/SECURITY.md +3 -1
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +56 -8
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +103 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +1 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +160 -19
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +18 -3
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +193 -13
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
  42. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +2 -2
  43. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +5 -2
  44. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +46 -0
  45. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +16 -15
  46. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +7 -1
  47. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +209 -0
  48. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +34 -6
  49. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +29 -5
  50. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +362 -7
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +35 -6
  52. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +4 -1
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +8 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,206 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.63.0 (2026-11-05)
4
+
5
+ ### Added
6
+
7
+ - working-with-fabric: a session Fabric started on a board request (FAA-201, Fabric's on-demand spec at `1329b60f`).
8
+ A session is the run only when its own prompt's first line `fabric-dispatch/1 workflow=wf_<16 hex> handoff=<id>
9
+ request=<uuid>` names the same three as `FABRIC_DISPATCH_WORKFLOW`, `FABRIC_DISPATCH_HANDOFF` and
10
+ `FABRIC_DISPATCH_REQUEST` (Switchboard's `dispatch` sets all three and writes the line, SB-247, released in
11
+ Switchboard 0.7.8 on 2026-11-04); the environment alone makes no run, since a reader or a subshell inherits it, and a
12
+ line that disagrees, or an environment without all three, makes none either: that session says so in the request's
13
+ thread and stops (Fabric's spec 3.5 on its main at `deaf51fe`, agreed on the project board, 2026-11-05). A line with no workflow in the
14
+ environment is quoted text. The session-start hook, which sees no prompt yet, prints the exit contract on that
15
+ condition in place of the preamble (naming the session's own `sessionId` when it is a UUID, and after a `/clear`
16
+ the session id the clear replaced, which a replayed accept needs for its `leaseId` and a pending handoff is accepted
17
+ under, or the new id when the replaced one is unknown), a retro before the run ends, also in a session
18
+ Fabric launched: `fabric_whoami`, the packet
19
+ (`observatory_checkpoint_latest`), the request and its thread (`com.get`, `com.list`), the lease
20
+ (`observatory_handoff_accept` with its owner, key and the executor the launcher names), `com.status` and the claim,
21
+ a request an earlier run left accepted or in progress (`com.responder_replace`, `com.reconcile`), in_progress
22
+ checkpoints, renewals about every 20 s (work that outlasts the run gives its state in a `com.progress` note and is left accepted for the next run,
23
+ which Fabric's dispatcher starts since its main at `fff4b6d9`, in no release yet, Fabric itself moving the open
24
+ Observatory workflow to that run, as its spec at `c560d97e` says (sections 3.4 and 3.6; its code does not yet); when the request's deadline is not after
25
+ run-ends-at plus 10 minutes, since no next run starts that close to it (`RUN_END_TO_DEADLINE_MS` on Fabric's main at
26
+ `afe28a60`, in no release yet), it fails known instead), the reply and `com.complete`, and the closing checkpoint with its
27
+ `workflowId` (`done`, or for `failed_known` `failed` once Observatory takes it, OBS-323, until then `blocked` with
28
+ the reason). The prompt hook, which sees the line, tells the session it is the run (or, for a line that disagrees,
29
+ that it is none), and prints no unread line or nudge in such a session. Only the
30
+ first session whose line names the dispatch holds it: another one gets one line
31
+ telling it not to accept, claim, complete or close (the dispatch is told by its `FABRIC_DISPATCH_ID` when the
32
+ launcher sets one in the form `[A-Za-z0-9._-]{1,64}`, as Switchboard 0.7.8 does), and a `/clear` in the holding session keeps the contract (the
33
+ hooks keep one record per run under `dispatch/`, pruned after 7 days by the Claude Code turn and end hooks and by the next session start on a board request). Every
34
+ checkpoint the session writes repeats the goal, constraints, artifacts and credentials of the packet's
35
+ `checkpoint.body`, since Observatory keeps only the latest body; the session takes the request from the environment
36
+ or the first line and its thread from `com.get`, since Observatory's write redacts the packet's board references. A
37
+ Codex run gets the hook only where its `CODEX_HOME` trusts it (Switchboard's isolated homes carry the person's trust since 0.7.8); a host whose hook gets no session id (the native bridges) is always told
38
+ the conditional contract and writes no record; on Windows, where each hook runs under a new Git Bash, a `/clear` holds
39
+ nothing (it cannot tell the holder from a helper), so the cleared session is told it runs inside a run that holds it,
40
+ stays a helper and Fabric takes the workflow over after the run. On any system, a `/clear` while another session
41
+ holds the dispatch (a nested session's clear) is told the same.
42
+ `watch-mail` refuses to start in a dispatched session, and the dashboard-kit notice says nothing there. `references/on-demand-session.md` holds the steps and every argument; the
43
+ hooks tell a dispatched session from this version on.
44
+ - working-with-fabric: whatever waits on the Project is answered in the same turn (FAA-205, the operator's rule of
45
+ 2026-11-04, board thread `11646774`): the session-start preamble, the unread line (`N unread on the project board:
46
+ com.list; answer what waits on you in this turn: ...`, its prefix unchanged for watchers) and the skill's board step
47
+ say to answer a request, an announcement or a question with its state now (done, where, when, what blocks) and send
48
+ the result later in a second message; `references/project-board.md` gains "Answering at once".
49
+ - The owner's word is done the first time (FAA-207, the operator's rule of 2026-11-04 relayed on the board, thread
50
+ `a7a9d05d`; the contract's CT-220): working-with-fabric's Step 5 and creating-fabric-agents' practices say the agent
51
+ does it itself, hands the person no command to run, asks one short question on a risk and then acts, and passes on
52
+ only a human gate with its reason; money limits and secrets stay.
53
+ - The channel-agent standard v1 at its round 4 (FAA-197): creating-fabric-agents' practices gain a section of the
54
+ standard's rules for agents on a channel people write to (the ticket as the unit, one outcome per ticket, answer
55
+ rather than route, repair before hand-off, declared sources with a maximum age, hand-off queues with their answered
56
+ signal, text from a channel as data (the contract's proposed CA-8), metadata only, the declaration's form, with a
57
+ holding reply owing a final text, FAC-SEM-164). The support-agent recipe:
58
+ - a hand-off its checks made carries the refused `draft` in the returned decision and to an `on_handoff` the agent
59
+ passes, never into a file (`decisions.jsonl` or the default outbox), so a repeat of the id returns it without; `answer(new_id, text, repair=decision)` rewrites it once with the refusal's reasons: a pass is
60
+ a reply with `repaired: true`, and a repair that ends otherwise (a second refusal, a model `HANDOFF`, no knowledge on
61
+ the retry; not a `model:*` outage) a hand-off with `fallback: true` (the standard's 4.4 and its ticket's
62
+ `fallback`), which `repair=` then refuses, as it refuses a repair under the refused decision's own message id;
63
+ - `KnowledgeSource` (or `knowledgeMaxAgeSeconds` in the config, which reads the file at once) reads the knowledge
64
+ file as a source: read again when it changes (its time, size, inode or status-change time, the last a creation time on Windows), the last good version kept when a read fails or the file has the
65
+ wrong shape (nested too deep included), `state()` with the age of that version, from its file's modification
66
+ time;
67
+ - `Knowledge.load` refuses a file that is not JSON, one nested deeper than 32 levels (on every Python), a file that is not an object with an `entries` list, an entry whose tags are not a list
68
+ of text or whose question is not text, each as a `RecipeError`;
69
+ - `channel-agent.example.json`, one project's declaration under
70
+ `https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1` in the round 4 form, held to it and to
71
+ the example config's maximum age by `test/test_channel_agent_example.py`.
72
+
73
+ ### Upgrading
74
+
75
+ - A recopied `support_agent.py` refuses at start (exit 1, a `RecipeError` naming the entry or the file's shape) a knowledge file 0.62.0
76
+ took in other ways, so an agent that ran on 0.62.0 can stop at start now. Per shape, 0.62.0: a file that is not an
77
+ object with an `entries` list (`[]`, `{}`, a string) loaded as an empty set and handed every message off as
78
+ `no-knowledge`; `{"entries": null}` or `{"entries": <number>}` stopped it at start with a `TypeError`; an entry with `tags: null`
79
+ failed each message with a `TypeError`; an entry whose `tags` is a string or holds something other than text, or
80
+ whose `question` is not text, was read and answered from. A file nested deeper than 32 levels, which 0.62.0
81
+ loaded, is refused too. Fix the file before upgrading.
82
+ - A custom `on_handoff` that stores the row it is given now receives the refused `draft`, which can echo what the
83
+ customer wrote: drop `draft` before storing it (the default outbox does).
84
+ - A session carrying `FABRIC_DISPATCH_WORKFLOW` now gets the exit contract at session start, on the condition that its
85
+ own prompt starts with the dispatch line naming the same three, even when Fabric launched it; with the workflow
86
+ set but the handoff or the request missing or malformed, it gets one line that no session there is a run. Its prompt hook prints no unread line or reminder (the nudges file
87
+ stays), and `watch-mail` refuses to start there. A launcher that sets the workflow alone starts no run any more.
88
+ - The live docs name a closed module neutrally, as the organization edition (`references/package.md` in
89
+ building-fabric-services and two backlog rows), at its owner's request; dated reports and handoffs keep their words.
90
+ - A session that does a board request with no person in its chat stops before a production step and names it for a
91
+ person, unless the operator's own words, relayed verbatim with their date and source from one of his own projects (provenance `trusted`) and with a scope naming the step, approve it (working-with-fabric `references/project-board.md`; board threads `1add3c03` and `24e0c88e`; Fabric's board rule at `108239c0`).
92
+
93
+ ### Fixed
94
+
95
+ - Every annotation in the shipped scripts (the kit, the skills' scripts, the recipe and the Hermes adapter) resolves
96
+ with `typing.get_type_hints` (FAA-203, a board finding, thread `9fd84d47`): `fabric_estate.py` and four working-with-fabric modules import the `typing` names they use, and
97
+ `LoginCodes`' `token` is written `Optional[Union[str, Callable[[], str]]]`, which Python 3.9 reads. A vendored copy
98
+ of `fabric_estate.py` or `fabric_auth.py` reads as older; it worked at run time before.
99
+
100
+ ## 0.62.0 (2026-11-04)
101
+
102
+ A new install names the repository of its code, and a service says what it uses, so a map of the installed agents can
103
+ link each to its project and draw its chain (FAA-198, FAA-199).
104
+
105
+ ### Added
106
+
107
+ - The reference service declares its connections (`extensions/connections/0.1`): the template it drafts from and the
108
+ demo source `serve --degraded` marks failing; the online example (`sample-remote-service.mjs`) declares an empty list,
109
+ since it calls nothing outside itself. The support-agent recipe's `SupportAgent.connections()` names its two models
110
+ for the service that runs it, keeping only an id the connections rule accepts, and imports `fabric_connections` only
111
+ when called (the recipe's install line names the module). `check_service.py` reports `well-known.connections` as `NOT_RUN` when the
112
+ well-known document has no connections declaration (it reported nothing before); an empty list is one and reads
113
+ `PASS`, and adapting-projects-to-fabric names the declaration in its
114
+ adapter step (FAA-199).
115
+
116
+ ### Changed
117
+
118
+ - `write_descriptor` / `writeDescriptor` refuse a new descriptor that names no `source.repository` (no descriptor of its
119
+ `id.instance` is installed yet: an installed one is a regular file, whatever it holds, so a folder, a broken link or
120
+ a path that cannot be examined is none), with `ServiceError` naming the descriptor; `register_remote` / `registerRemote` refuse it before they
121
+ write the token file, so a refused registration leaves nothing, and a rotation that names no `source` keeps the
122
+ installed one when the kit accepts it (a hand-edited one it refuses is dropped, as before). Rewriting a descriptor already installed without it still writes it and warns
123
+ (`NoSourceRepositoryWarning`, a `UserWarning` attributed to the installer's call; Node `process.emitWarning`, code
124
+ `FABRIC_NO_SOURCE_REPOSITORY`), so an installed agent's update keeps working. `source_refusal` / `sourceRefusal` say
125
+ why an install would be refused, for an installer that checks before it validates or writes anything else (a `source`
126
+ that is no object names nothing), and `names_repository` / `namesRepository` whether a descriptor names one.
127
+ `check_service.py` names a descriptor without it, or with one the descriptor's rules refuse, and why
128
+ (`descriptor.source-repository`, `NOT_RUN`, never a pass), and prints a repository without its userinfo, query or
129
+ fragment (a URL through `redact_url`; a local path, one that starts with `/`, `~`, `.`, `\\` or a drive, by its name;
130
+ a value of a URL scheme such as `https:` also without `//`, through `redact_url`; an scp form or any other value from
131
+ after its user and password, whatever they hold; one with a piece shaped like a credential that is no name not at
132
+ all), as
133
+ `state.outside-code` and `dashboard.opens-in-host` now do too for the source and for the data checkout's origin (they
134
+ printed them as found).
135
+ `sample_service.py register` writes this repository, whose code its descriptor runs whatever `--id` names, takes
136
+ `--repository`, and writes its manifest only after the descriptor, so a refusal of any kind leaves none naming it, and
137
+ a manifest it cannot write takes a new descriptor back with it, or puts an installed one's bytes back; an installed
138
+ descriptor it cannot read is never replaced (it refuses before writing anything). No owner field is added: the
139
+ organization follows from the repository (FAA-198). `source.repository` is read for a credential by one rule in both kits and
140
+ in the provider entry's check (`repository_credential` / `repositoryCredential` in `fabric_secrets`): its user, its query
141
+ and fragment as `secret_url` reads them (a parameter named for a credential, such as `access_token`, now counts), and
142
+ a path segment shaped like a key; a path segment in lower case, or of letters with no digit, is the repository's
143
+ name unless a key's shape (such as `sk-`, `ghp_`, `xoxb-`, `glpat-`, `AKIA`, `eyJ`) starts it or follows a `.`,
144
+ `_` or `-` in it, so `task-pipeline-dashboard` and `Task-Pipeline-Dashboard`, which read as an `sk-` key, are accepted (a source with it was refused before). A user is accepted only in a URL's
145
+ authority (`https://user@host/path`): an scp form or an opaque URI holding an `@` (`bot:...@host:path`,
146
+ `https:user@host/path`) is refused, and so is a password in the user information read any way a reader takes it:
147
+ as written (`https://h\u:pw@x/r`, as git reads it) or as the URL standard does (`https:///u:pw@h/r`). Node reads
148
+ the user information and parameters by hand, as Python does, so a special scheme written without `//`
149
+ (`https:host/r?access_token=...`) or a host `new URL` refuses is read alike.
150
+
151
+ ### Fixed
152
+
153
+ - `redact_url` / `redactUrl` (the URL an `HttpError` carries and `check_url` / `checkUrl` print) dropped a user
154
+ information only as the URL standard reads it, so `https://h\u:pw@x/r`, which git and Python's `urllib` read as a
155
+ password, printed `pw` in its path in both kits (0.61.0 too); they now drop the user information as
156
+ written first, after any run of slashes or backslashes that follows the scheme (`https:/h\u:pw@x/r`,
157
+ `https:\\h\u:pw@x/r`; 0.62.0 seam readings 11 and 13).
158
+ - Node's `checkUrl` sent `ht<tab>tps://h\u:pw@x/r` and `https:\/h\u:pw@x/r`, which Python's `check_url` refuses
159
+ (since 0.60.0): it now reads the written user information on the text `new URL` reads, after any run of slashes.
160
+ - Node's `redactUrl` printed `<unparseable url>` for a URL of a scheme other than http, https, ws, wss or ftp (`file:`
161
+ included) that `new URL` cannot parse (`ssh://[zz]/r`, `ssh://h:99999/r`, `file://h:1/r`), where Python keeps the
162
+ scheme: both now print `<scheme>:<redacted>`.
163
+ - A subscription `runner.kind` that is no string (a list or an object) raised `TypeError` in Python's
164
+ `validate_descriptor`, `write_descriptor` and `check_service.py` (0.61.0 too), and Node, for a list whose text is a
165
+ subscription runner's name (`["claude-code"]`, which `Object.hasOwn` reads as that key), named only one of its two
166
+ problems: both kits now name both as descriptor problems.
167
+ - Node's key set cache (`JwksCache`) accepted `https://h\u:pw@x/j` and `https://@x/j`, which the Python twin refuses:
168
+ it now reads the authority as written too.
169
+
170
+ ### Upgrading
171
+
172
+ - An installer that writes a descriptor for a new install (`register_remote` / `registerRemote` included) and a desktop
173
+ app that writes its own (DEC-0069) pass `source: {"repository": "https://github.com/owner/name"}` (the repository
174
+ their code is built from) in the call that writes it: a source set by a later write is too late, since the first call
175
+ is refused before anything is written. An installer that loads the kit from the installed plugin meets this as
176
+ soon as the plugin updates; an installed agent's next update warns until it names one. Only a rewrite in
177
+ place keeps working: an update that removes the descriptor before it writes it again, a new instance (a preview) and a
178
+ moved services directory are new installs. In Python the warning is `NoSourceRepositoryWarning` (a `UserWarning`), so
179
+ a suite that runs with warnings as errors (`-W error`) sees the rewrite raise; filter that class there.
180
+ - Once a descriptor names its repository, two probe rules that read `NOT_RUN` without it start judging:
181
+ `state.outside-code` fails data kept inside that checkout, and `dashboard.opens-in-host` reads the checkout for pages
182
+ opened in a browser. A service that passed may fail them until its data moves out of the checkout or its pages open
183
+ through Fabric Dashboards.
184
+ - Declare what the service uses with `connections_extension` / `connectionsExtension` (an empty list when it uses
185
+ nothing): until it does, `well-known.connections` reads `NOT_RUN` and the connection map has no row for it.
186
+ - A `source.repository` (a service's or a provider entry's) that names a user outside a URL's authority (an scp form,
187
+ `https:user@host/path`) or carries a query parameter named for a credential is refused: write it as
188
+ `https://github.com/owner/name`, with no user, query or fragment. A path segment that mixes upper case with a digit
189
+ is read for a key's shape (`sk-` and 16 more characters, a `ghp_` run, 40 characters of mixed case and digits), so
190
+ `Task-Pipeline-Dashboard2` is refused while `Agent-V2` is not: write the repository's name in lower case (hosts such
191
+ as GitHub take either). A segment that a key's shape starts, or follows a `.`, `_` or `-` in, is refused whatever
192
+ its case and also percent-encoded (`%73k-` reads as `sk-`). The shapes are those of `fabric_secrets.SECRET_VALUE`,
193
+ such as `sk-` and 16 more characters, `gh` and one of p, o, u, s, r then `_` and 20, `xox` and one of a, b, o, p, r,
194
+ s then `-` and 10 (so the word `xoxo-` counts), `glpat-` and 20, `AKIA` and 16, and a JWT as written (`eyJ` in
195
+ that case, `JWT_SHAPE`); `bearer` and a space counts at a segment's start once decoded (`Bearer%20Tracking` is
196
+ refused, `team-Bearer%20Tracking` stays a name), and anywhere in a segment or a host when 12 or more token
197
+ characters with a digit follow it and no letter, digit or `_` comes before it (`x-Bearer%20<a token>` is refused).
198
+ A host is read for a key's shape anywhere in it, inside a word too, so `task-pipeline-dashboard.example.com` is
199
+ refused although the same path segment is a name (FAA-200 (32)). Such a host, like a repository such
200
+ as `sk-learn-` followed by a longer name, has no spelling the kit accepts, so an agent built from it is not installed
201
+ new, and
202
+ an installed one keeps working by a rewrite in place that leaves the source out (FAA-200 (24)).
203
+
3
204
  ## 0.61.0 (2026-11-04)
4
205
 
5
206
  The board responder reads the operator's mandate from a request of his own Project (FAA-135), and working-with-fabric
package/README.md CHANGED
@@ -23,7 +23,8 @@ The skills are `adapting-projects-to-fabric`, `creating-fabric-agents`,
23
23
  `working-with-fabric` is for the other side: a coding agent (Claude Code, Codex, Kimi Code, Kilo, Hermes) opened in
24
24
  a terminal that works in a project through Fabric. It finds Fabric (`hub.json`, its `pid`), has the machine's
25
25
  lifecycle broker start it when it is down, registers `fabric-mcp` once with no secret, calls `fabric_whoami`
26
- first, reads the project board (`com.status`, `com.list`, `com.read_ack` only what was read) and asks other
26
+ first, reads the project board (`com.status`, `com.list`, `com.read_ack` only what was read), answers what waits on its
27
+ Project in the same turn with its state (the result later; a request a responder should take stays queued), and asks other
27
28
  Projects only through `com.submit`, asks who is working on what and claims its task before it starts
28
29
  (`fabric_work_list`, `fabric_work_claim`, Fabric v0.3.5 and later), links tasks to backlog rows instead of copying them, and
29
30
  releases at the end (`fabric_work_release`). `scripts/fabric_session.py status` prints where Fabric stands and one
@@ -37,12 +38,14 @@ In Claude Code the plugin adds four session hooks: SessionStart (at startup and
37
38
  resume) puts that preamble into the session when the MCP server `fabric` is registered, Fabric runs and the project
38
39
  uses Fabric (one line pointing at `fabric_whoami` in a folder with no sign of it); when Fabric
39
40
  is down it adds one line (skip `fabric_whoami`, start Fabric through the broker if the task needs it), and only in a
40
- project that uses Fabric; Stop records each turn's facts (HEAD, files changed, lines added and removed, never content)
41
+ project that uses Fabric; in a session Fabric started on a board request (`FABRIC_DISPATCH_WORKFLOW` set) it prints the
42
+ exit contract instead, on the condition that the session's own prompt starts with the dispatch line naming the same
43
+ three, and UserPromptSubmit, which sees that line, confirms the run or says it is none (FAA-201); Stop records each turn's facts (HEAD, files changed, lines added and removed, never content)
41
44
  and the board's unread count, and, once the running Fabric takes them (unless `FABRIC_SESSION_NO_REPOSITORIES=1`), the other repositories the turn touched (it then
42
45
  reads the last 512 KB of the session transcript (message text and tool output included, which can hold a secret the session displayed; read in memory only), uses only tool names and path arguments, and sends host/owner/name, never a
43
46
  local path or text); UserPromptSubmit adds one line only when that count rose, and each reminder Fabric gave on a turn once (a turn `watch-mail` delivered prints there instead); SessionEnd releases what the
44
47
  session left claimed. Stop, UserPromptSubmit and SessionEnd spool and call Fabric only when the running Fabric
45
- advertises `session-hooks/1`; Stop and SessionEnd always prune their own stale spool folders. All are silent in a session Fabric launched, never block the session, read no Fabric token (an `origin` URL's embedded credential is dropped in memory, never
48
+ advertises `session-hooks/1`; Stop and SessionEnd always prune their own stale spool folders. All are silent in a session Fabric launched (except the exit contract in one started on a board request, where the prompt hook prints no unread line or reminder), never block the session, read no Fabric token (an `origin` URL's embedded credential is dropped in memory, never
46
49
  kept or sent) and always
47
50
  exit 0 once `python3` starts (on Windows, Claude Code runs hooks in Git Bash, or PowerShell without it, where `python3`
48
51
  must name Python 3.9 or later; FAA-183). `fabric_session.py watch-mail` waits for board mail under a background watcher, and `install-host-hooks
package/SECURITY.md CHANGED
@@ -47,7 +47,9 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
47
47
  remote, `owner` is the folder path on that server before the repository's name). The mark is
48
48
  removed only by a worker that sees a schema without the field, so after Fabric is downgraded
49
49
  the turns that end before a worker next reaches the running Fabric (about a minute; longer while
50
- rejoins fail) are still read; none of their names is sent. They write their spool and log under the user's cache folder (0600),
50
+ rejoins fail) are still read; none of their names is sent. They write their spool and log under the user's cache folder (0600), and, in a session
51
+ started on a board request (FAA-201), one record of which session holds that dispatch (the holding session's id and, after a `/clear`, the id it replaced, the agent's process id and a time, 0600,
52
+ pruned after 7 days),
51
53
  and the delivery worker starts `fabric-mcp` in its own process group and ends that group
52
54
  (`killpg`, or `taskkill /T /F` on Windows) at its 10 s deadline. They read no Fabric token. An
53
55
  `origin` URL that embeds a credential (`https://user:token@host/...`) is read by `git config`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -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; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.61.0",
6
+ "version": "0.63.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Two silent-unless-relevant hook sets. The dashboard-kit notice: one line at session start or /clear in a repository that owns a Fabric service whose dashboard has no kit or an older one (fabric-dashboard/0.1, DEC-0036), phrased as an upgrade the agent may make itself. The Fabric session hooks (working-with-fabric, COM-04, FAA-12a): at session start or /clear, the preamble when Claude Code has the MCP server `fabric` and Fabric runs (fabric_whoami, then the project board through com.status and com.list, claim and release); when Fabric is down, one line, only in a project that uses Fabric (a Fabric contract file, a project-bound registration or a service descriptor naming the repository); nothing on compact or resume; each turn's facts (HEAD, files changed, lines added and removed, never content), the board's unread count, and, once the running Fabric's fabric_session_observe takes them (unless FABRIC_SESSION_NO_REPOSITORIES=1), the other repositories the turn touched (the last 512 KB of the session transcript, message text and tool output included (a secret the session displayed can be there; read in memory only), is then read and only the current turn's tool names and path arguments are used; host/owner/name leave the machine, never a local path or text) and the session's end through Fabric, only when the running Fabric advertises session-hooks/1; at each prompt one line only when the unread count rose, then each nudge a turn's answer carried, once (watch-mail prints those of turns it delivered). All silent in a session Fabric launched. Every hook reads no Fabric token (a credential embedded in an origin URL is read by git config and dropped in memory, never spooled or sent), prints at most one block, always exits 0 once python3 starts (on Windows it must name Python in Git Bash, FAA-183), and hands any Fabric call to a detached worker with a 10 s deadline.",
2
+ "description": "Two silent-unless-relevant hook sets. The dashboard-kit notice: one line at session start or /clear in a repository that owns a Fabric service whose dashboard has no kit or an older one (fabric-dashboard/0.1, DEC-0036), phrased as an upgrade the agent may make itself. The Fabric session hooks (working-with-fabric, COM-04, FAA-12a): at session start or /clear, the preamble when Claude Code has the MCP server `fabric` and Fabric runs (fabric_whoami, then the project board through com.status and com.list, claim and release); when Fabric is down, one line, only in a project that uses Fabric (a Fabric contract file, a project-bound registration or a service descriptor naming the repository); nothing on compact or resume; each turn's facts (HEAD, files changed, lines added and removed, never content), the board's unread count, and, once the running Fabric's fabric_session_observe takes them (unless FABRIC_SESSION_NO_REPOSITORIES=1), the other repositories the turn touched (the last 512 KB of the session transcript, message text and tool output included (a secret the session displayed can be there; read in memory only), is then read and only the current turn's tool names and path arguments are used; host/owner/name leave the machine, never a local path or text) and the session's end through Fabric, only when the running Fabric advertises session-hooks/1; at each prompt one line only when the unread count rose, then each nudge a turn's answer carried, once (watch-mail prints those of turns it delivered). All silent in a session Fabric launched. Every hook reads no Fabric token (a credential embedded in an origin URL is read by git config and dropped in memory, never spooled or sent), prints at most one block, always exits 0 once python3 starts (on Windows it must name Python in Git Bash, FAA-183), and hands any Fabric call to a detached worker with a 10 s deadline. In a session Fabric started on a board request (FABRIC_DISPATCH_WORKFLOW), session start prints the exit contract instead of the preamble on the condition that the session's own prompt starts with the dispatch line naming the same three, launched or not, and the prompt hook, which sees that line, confirms the run or says it is none, launched or not, with no unread line or nudge, and the dashboard-kit notice says nothing (FAA-201).",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -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.61.0"
8
+ version: "0.63.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "3830a3353923e20f826d466610e1c479f120bec0"
11
11
  ---
@@ -143,6 +143,9 @@ Preserve the chosen ownership boundary:
143
143
  `building-fabric-services`'s `scripts/fabric_interop.py`.
144
144
  - Local runner maps typed input, executable identity, result location, cancellation,
145
145
  heartbeat, and partial results without relying on ambient accounts.
146
+ - A service declares what it uses (`extensions/connections/0.1`: models, other agents, connectors, stores,
147
+ channels) with `connections_extension` / `connectionsExtension`, and its descriptor names `source.repository`;
148
+ the probe names either one missing (FAA-198, FAA-199).
146
149
 
147
150
  Keep model selection and provider-internal reasoning outside the Fabric contract. Expose
148
151
  typed outcomes, evidence, artifacts, and protocol-visible state, not chain-of-thought.
@@ -57,8 +57,10 @@ It refuses, with one sentence that never quotes a value:
57
57
  (`test_provider.SchemaParityTests` compares them with a contract checkout at the pin when `FABRIC_CONTRACT_NEW` names
58
58
  one, as the contract job of the nightly CI does), and the reader is held to every `provider.schema.json` fixture of
59
59
  the pin (`test/fixtures/contract/provider-fixtures.json`, `test_provider.ContractFixtureTests`);
60
- - a `source.repository` with a `user:password@` or a value shaped like a credential: it names the repository, never
61
- reaches it (since 0.49.3, as the service kit's descriptor check);
60
+ - a `source.repository` with a `user:password@`, a user outside a URL's authority, or a value shaped like a credential
61
+ in its user, query or fragment or as a path segment (one in lower case, or of letters with no digit, is the name unless a key's shape starts it or follows a `.`, `_` or `-`, read as written and percent-decoded; a host is read for a key's shape anywhere in it,
62
+ inside a word too, so `task-pipeline-dashboard.example.com` is refused, FAA-200 (32)): it names the
63
+ repository, never reaches it (since 0.49.3; one rule with the service kit's descriptor check since 0.62.0);
62
64
  - a manifest whose name follows a backslash (`C:\Agents\example\fabric-agent.json`). The reader,
63
65
  `validate_provider_entry`, accepts it since DEC-0145 (`MANIFEST_PATTERN`, the schema's), but a reader that has not
64
66
  re-pinned to DEC-0145 refuses the entry, Fabric's registry among them (CT-192), so the writer does not emit it until
@@ -147,7 +147,7 @@ def validate_provider_entry(entry: Any) -> List[str]:
147
147
  problems.append("source is {repository?}")
148
148
  elif "repository" in source and not _is_uri(source["repository"]):
149
149
  problems.append("source.repository is an absolute URI")
150
- elif "repository" in source and (_URI_PASSWORD.match(source["repository"]) or _secrets.secret_argument(source["repository"])):
150
+ elif "repository" in source and (_URI_PASSWORD.match(source["repository"]) or _secrets.repository_credential(source["repository"])):
151
151
  # FAA-120, as the service kit: a repository is named, never reached with a credential in its URI.
152
152
  problems.append("source.repository carries no password or credential: it names the repository")
153
153
  if not isinstance(entry["installedAt"], str) or not _TIMESTAMP.fullmatch(entry["installedAt"]):
@@ -185,3 +185,60 @@ def secret_url(value: str) -> bool:
185
185
  if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
186
186
  return True
187
187
  return False
188
+
189
+
190
+ _REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
191
+ _REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
192
+ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
193
+ _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % SECRET_VALUE.pattern, re.IGNORECASE | re.ASCII)
194
+ _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
195
+
196
+
197
+ def repository_name(piece: str) -> bool:
198
+ """Whether a path segment reads as a repository's name: lower case, or letters with no digit, with no provider
199
+ key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
200
+ readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
201
+ inside a word, is a name."""
202
+ return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not _JWT_AT_A_WORD.search(piece)
203
+
204
+
205
+ def ascii_decoded(text: str) -> str:
206
+ """Every `%XX` escape of an ASCII character decoded, the rest left as written: how a repository's path and user
207
+ information are read as well as written, since git and hosts decode them (`%73k-` is `sk-`, `u%3Apw` is `u:pw`;
208
+ 0.62.0 readings 11). The Node twin is asciiDecoded."""
209
+ return re.sub(r"%([0-7][0-9A-Fa-f])", lambda escape: chr(int(escape.group(1), 16)), text)
210
+
211
+
212
+ def repository_password(value: str) -> bool:
213
+ """Whether a repository URI's user information holds a password, in any scheme, `file` too: every slash and
214
+ backslash after the colon skipped, then up to the next `/`, `?` or `#`, a backslash kept in the user. That one
215
+ reading holds git's (`https://h\\u:pw@x/r`), curl's (`https:///h\\u:pw@x`) and the URL standard's
216
+ (`https:///u:pw@h`, which stops at a backslash, a prefix of it) (0.62.0 readings 10 to 14). The user information
217
+ ends at the last `@`; a password follows its first `:`, as written or ascii_decoded. The Node twin is
218
+ repositoryPassword."""
219
+ value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
220
+ scheme, colon, rest = value.partition(":")
221
+ authorities = [re.match(r"[^/?#]*", rest.lstrip("/\\")).group(0)] if colon else []
222
+ users = [authority.rpartition("@")[0] for authority in authorities]
223
+ return any(user.partition(":")[2] for user in users + [ascii_decoded(user) for user in users])
224
+
225
+
226
+ def repository_credential(value: str) -> bool:
227
+ """FAA-198 (0.62.0 readings 6 and 7): whether a repository URI carries a credential: in its scheme and authority (a
228
+ user), in its query or fragment as secret_url reads them, or as a path segment shaped like a key. A path segment in
229
+ lower case, or of letters with no digit (task-pipeline-dashboard, Task-Pipeline-Dashboard), is the repository's
230
+ name, never a key, though it reads as an `sk-` shape, unless a key's shape starts it or follows a `.`, `_` or `-` in
231
+ it (repository_name); a segment is read as written and ascii_decoded. A
232
+ user outside a URL's authority (an scp form, `https:user@host/path`) counts as one: a user is an authority's."""
233
+ if "@" in value and not _REPOSITORY_AUTHORITY.match(value):
234
+ return True
235
+ match = _REPOSITORY_URI.match(value)
236
+ if not match:
237
+ return secret_argument(value)
238
+ head, path, tail = match.groups()
239
+ if repository_password(value):
240
+ return True
241
+ odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
242
+ if piece and not repository_name(piece)]
243
+ # the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
244
+ return secret_url(head + tail) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.61.0"
17
+ version: "0.63.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "3830a3353923e20f826d466610e1c479f120bec0"
@@ -65,16 +65,17 @@ Answer before writing code, and record the answers in the project README:
65
65
  | What it stores | the store, where it lives (Step 2, rule 5), and what survives uninstall |
66
66
  | Data and uptime | kind U or O, kit folders, a durable store or `<id>-data`, uptime A or B; a new agent's `LICENSE` holder, transfer audit per release (creating-fabric-agents) |
67
67
  | What it tells the operator | the events worth a notification (Step 5) |
68
+ | Source | `source.repository`, required (Step 4); connections, `[]` if none |
68
69
 
69
70
  ## Step 1: choose the surfaces
70
71
 
71
72
  | Caller | Surface | Why |
72
73
  |---|---|---|
73
- | Another agent on this machine | MCP `2026-07-28`, `streamable-http`, at `/mcp` on the service origin, token in a header | one process serves every session; stdio-per-session once left twenty stray servers on this machine |
74
+ | Another agent on this machine | MCP `2026-07-28`, `streamable-http`, at `/mcp` on the service origin, token in a header | one process serves every session (stdio-per-session once left twenty stray servers) |
74
75
  | The operator or a script | a CLI: `<tool> service status\|start\|stop\|restart`, `<tool> dashboard` (opens only in Fabric Dashboards, below), `<tool> doctor --json` | every verb delegates to launchd and the well-known document, never to its own process table |
75
76
  | A remote agent | A2A `1.0` over HTTPS, or MCP behind an authenticated gateway | loopback reachability is not authorization |
76
77
  | An A2A peer on this computer | `surfaces.a2a` names a public Agent Card, served without the token; its interface URLs are loopback (DEC-0122); the kits read it, serving one is FAA-10 | the card carries no credential, process member or required Fabric extension |
77
- | The dashboard page | same-origin REST under `/api`, session cookie plus a custom request header; every button on it also a tool on the MCP surface (Step 5) | the CSRF defence all four existing dashboards use |
78
+ | The dashboard page | same-origin REST under `/api`, session cookie plus a custom request header; every button on it also a tool on the MCP surface (Step 5) | the existing dashboards' CSRF defence |
78
79
 
79
80
  Read [the surfaces and auth reference](references/surfaces-and-auth.md) when wiring
80
81
  MCP registration into a client config, the login flow or the CSRF header.
@@ -213,8 +214,8 @@ descriptor's `fabricManifest` at a manifest whose service key names this
213
214
  The installer, not the service, owns the plist and the descriptor. Sequence:
214
215
 
215
216
  1. `write_descriptor(descriptor)`: refuses a port or `id.instance` another descriptor
216
- claims. A preview or branch copy is a second **instance**, never a second id, and it is
217
- one more service the operator sees, probes and gets notified by. Verify a release with its
217
+ claims, and a new one without `source.repository` (a rewrite warns). A preview or branch
218
+ copy is a second **instance**, never a second id: one more service the operator sees. Verify a release with its
218
219
  own doctor or self-check before it replaces `default`; a temporary instance is uninstalled
219
220
  (`launchd_uninstall`, `remove_descriptor`) as soon as its check is done, not left running.
220
221
  2. `launchd_plist(...)` then `launchd_install(...)`: writes and lints the plist,
@@ -269,7 +270,7 @@ refusals.
269
270
  **Say what the agent uses** (`fabric-connections/0.1`, DEC-0096) so Fabric Dashboards draws its chain: the other
270
271
  agents, models, connectors, stores and channels, each with its state, from
271
272
  `connections_extension()` / `connectionsExtension()` in `fabric_connections`. Never a credential in it, never a call
272
- count (that is the usage report); the details are in [the wire reference](references/protocol.md).
273
+ count (that is the usage report); details: [the wire reference](references/protocol.md).
273
274
 
274
275
  The events feed is a **view over the log you already keep** (a jobs table, a JSONL
275
276
  journal), not a second store. Each event is one sentence a person reads; set
@@ -298,7 +299,8 @@ rule it can reach and says which it could not:
298
299
  python3 <skill-dir>/scripts/check_service.py <id>[.<instance>]
299
300
  ```
300
301
 
301
- It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, well-known
302
+ It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, `descriptor.source-repository`,
303
+ `well-known.connections`, well-known
302
304
  shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token file,
303
305
  events auth and shape, single-use login, state outside code, instance lock, plist,
304
306
  launchd pid equals answering pid, `dashboard.declared` (ED-9), `well-known.actions` (DEC-0121),
@@ -405,8 +407,8 @@ this estate needs.
405
407
  - **Not macOS / no launchd:** the kits write a systemd user unit (Linux) or a per-user Scheduled Task (Windows); with
406
408
  no supervisor at all declare `lifecycle.manager: "none"`, and hosts show the service read-only. Keep every other rule.
407
409
  - **A desktop app with no supervisor** (DEC-0069): its descriptor says `manager: "person"` with `lifecycle.app`, no
408
- origin, `installedBy {by: "person", role, tool}`; its live origin is in its own liveness file
409
- (the lifecycle reference, *An app's own descriptor and the node*).
410
+ origin, `installedBy {by: "person", role, tool}`, `source.repository` (the lifecycle reference, *An app's own
411
+ descriptor and the node*).
410
412
  - **No Python:** use the Node kit; run the probe from any machine that has Python and
411
413
  can reach the port, or mark the probe `NOT_RUN` with the reason.
412
414
  - **Neither kit fits the framework:** implement the four routes and the lock by hand
@@ -22,9 +22,14 @@ service that calls it owns its channel (mail, a chat, a store review) and its re
22
22
  This recipe is the draft-and-check step of an agent that answers people, not that agent, and used alone it falls short
23
23
  of one. Every hand-off goes to `on_handoff`, by default the outbox a person reads, at the first refusal: the practices
24
24
  name that as the failing pattern (P2). An agent that repairs passes its own `on_handoff=` so a failed check reaches
25
- its repair step rather than a person; the recipe returns the failed checks by name in `reasons`, but takes no reason
26
- in and returns no refused draft yet, and it calls again under a new message id, since the stored decision answers any
27
- repeat of the same id. `on_handoff` runs under the agent's lock, before the decision is saved, so it queues the
25
+ its repair step rather than a person; a hand-off the checks made carries the failed checks in `reasons` and the
26
+ refused `draft`, and the repair step passes that decision back as `answer(new_id, text, repair=decision)`: the model
27
+ gets its draft and the reasons, and a pass comes back as a reply with `repaired: true`, a second refusal as a hand-off
28
+ with `fallback: true` (the channel-agent standard's 4.4 and its ticket's `fallback`, FAA-197). It calls under a new message id, since the stored decision answers any repeat of the same id (`repair=` under the
29
+ refused decision's own id is refused with a `RecipeError`); `repair=` takes only a hand-off the checks
30
+ made (not `no-knowledge`, `model-unsure` or `model:*`), and never a repeat: a repeat of the id returns the stored
31
+ decision, which has no draft, so the repair step keeps the decision it was handed and passes each first decision to
32
+ `repair=` once. A repair the model declines (`HANDOFF`) or that finds no knowledge is marked `fallback: true` too: it marks a spent repair; a `model:*` outage is not one. `on_handoff` runs under the agent's lock, before the decision is saved, so it queues the
28
33
  hand-off and returns: since 0.55.0 a call to `answer` from inside it, on the same thread, is refused with
29
34
  `ReentryError` (a `RecipeError`), and unless `on_handoff` catches it the first call raises it too, its draft paid and
30
35
  nothing saved or delivered. A call it hands to another thread and waits for waits for the lock forever, so it never
@@ -42,8 +47,8 @@ are not counted. The recipe hands off nothing for `policy`: the calling service
42
47
  handle (refunds, unbans, account recovery) before it calls the recipe, or a model's `HANDOFF` on them is counted as a
43
48
  false knowledge gap. Count its hand-offs by cause: a failed check is a refusal for the agent to repair, research and
44
49
  hold, and only a hold on the material counts as `inability` (a hold after an outage or at the research time cap is
45
- `infrastructure`); `unsupported:amount` is a money reason, never researched, and so is `unsupported:number` while the
46
- decision returns no draft to tell a price from another number; `no-knowledge` and `model-unsure` are `inability`;
50
+ `infrastructure`); `unsupported:amount` is a money reason, never researched, and so is `unsupported:number` unless the
51
+ refused `draft` shows the number is not a price; `no-knowledge` and `model-unsure` are `inability`;
47
52
  every `inability` opens a gap; `model:*` is `infrastructure`, and `model:ceiling` and `model:payment` are also the
48
53
  turn's `limit` outcome. The rest of the agent's parts with checks come from [agents that answer
49
54
  people](../../../creating-fabric-agents/references/agents-that-answer-people.md): the turn record, the daily
@@ -71,13 +76,48 @@ names the same file.
71
76
 
72
77
  ## Install into a service
73
78
 
79
+ The service that runs it says what it uses (FAA-199): `agent.connections()` names the two models, and the service adds
80
+ its own hand-off route, in its well-known document:
81
+ `fs.connections_extension(agent.connections() + [...], degraded=degraded)`. The two take the ids `model` and `fallback`
82
+ (a `degraded` entry whose source is `fallback` marks the fallback); the service's own items take other ids, since one id
83
+ named twice is refused.
84
+
74
85
  ```bash
75
86
  python3 <adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py \
76
- install <service>/support --lang py --module fabric_openrouter --module fabric_usage --module fabric_paths
87
+ install <service>/support --lang py --module fabric_openrouter --module fabric_usage --module fabric_paths \
88
+ --module fabric_connections
77
89
  cp <adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py <service>/support/
78
90
  python3 <adapter>/.../fabric_kit.py status <service>/support # current, older or modified, per file
79
91
  ```
80
92
 
93
+ ## The knowledge file as a source
94
+
95
+ The channel-agent standard declares every source with its owner, how it is read, its cadence and its maximum age, and a source that fails
96
+ keeps its last good version and says so (its 7.1 and 7.4). `KnowledgeSource(path, max_age_s=...)` is the knowledge
97
+ file read that way: it reads the file again when it changes, keeps the last good version when a read fails, and
98
+ `state()` gives `{id, age_s, max_age_s, stale, degraded}`. The age is the last good version's, from its file's
99
+ modification time: a file nobody rebuilt grows old however often it is read, and a restart does not make it new.
100
+ The standard ages a source from its last finished sync, so the knowledge build writes or touches the file at every
101
+ successful rebuild, unchanged or not, and never keeps the source's time (`cp -p`, `rsync -t`). Pass it as `knowledge=`, or set
102
+ `knowledgeMaxAgeSeconds` in the config, which reads the file at once (a file never read well fails there). The calling service shows `state()`
103
+ on its page and opens one alert when `stale` turns true: the recipe has no page and sends no alert itself.
104
+
105
+ ## Declaring the channel-agent standard
106
+
107
+ An agent that answers people on a channel declares what it follows under
108
+ `https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1` (the contract's proposed DEC-0150, not
109
+ merged yet, so the key and the form may change), in its `fabric-agent.json` or its
110
+ well-known `extensions` (the standard's 11 and 13.6; the doctrine: creating-fabric-agents
111
+ [practices](../../../creating-fabric-agents/references/practices.md#agents-on-a-channel-people-write-to)).
112
+ `channel-agent.example.json` is one project's declaration in the standard's round 4 form: a required member that is
113
+ not there yet reads `{"none": "not_yet", "until": "<date>"}`, one not used `{"none": "not_used"}`, never null except
114
+ where nothing is decided (a channel's `decision`); a final text never `not_used` beside a holding reply with replies
115
+ on (FAC-SEM-164); `recommendation` only with `answered_signal: "none"`, the id of the
116
+ board thread where the queue's owner was told; a channel's `items_seen_unit` one of `mails`, `updates`, `messages`,
117
+ `reviews`; durations in ISO 8601; the knowledge file as a source with its
118
+ maximum age. `test/test_channel_agent_example.py` holds the example to that form; `check_service.py` reads the
119
+ declaration once the contract publishes its schema.
120
+
81
121
  ## Configure
82
122
 
83
123
  `config.example.json`, paths relative to the config's folder:
@@ -85,6 +125,7 @@ python3 <adapter>/.../fabric_kit.py status <service>/support # current, old
85
125
  | Key | Meaning |
86
126
  |---|---|
87
127
  | `knowledge` | the knowledge file: `{"entries": [{"id", "question", "answer", "tags"}]}`, ids unique |
128
+ | `knowledgeMaxAgeSeconds` | optional: the knowledge file is then a source with an age and a last good version (above) |
88
129
  | `stateDir` | the decisions (`decisions.jsonl`), the handoff outbox (`handoffs.jsonl`) and the usage ledger (`usage.jsonl`) |
89
130
  | `model`, `fallback` | two models of different vendors; a lane that reads what customers write runs on an open-vendor model (DEC-0083) |
90
131
  | `keyEnv` | the environment variable that holds the agent's own OpenRouter key; never a value in the file |
@@ -104,7 +145,11 @@ the store's runner, which puts the named key into this one process's environment
104
145
  `python3 "$(project-observatory full-path)/tools/use_secret.py" run --vault-only <project> OPENROUTER_API_KEY --
105
146
  python3 support_agent.py answer ...`); a long-running
106
147
  service gets it the same way from its service manager. The decision
107
- prints as JSON: `{"messageId", "action": "reply"|"handoff", "reply"?, "citations", "reasons", "costUsd", "repeated"}`.
148
+ prints as JSON: `{"messageId", "action": "reply"|"handoff", "reply"?, "citations", "reasons", "costUsd", "repeated"}`,
149
+ with `"draft"` on a hand-off the checks made, `"repaired": true` on a reply after `repair=` and `"fallback": true` on
150
+ a repair that ended in a second refusal, a declined repair (`HANDOFF`) or no knowledge; a `model:*` outage during a
151
+ repair is a plain hand-off without a draft. Two meanings of the word: the config's `fallback` is the second model; a decision's `fallback: true`
152
+ is the channel-agent standard's fallback, a spent repair (a second refusal, a declined repair or no knowledge on the retry, never a `model:*` outage). `repair=` refuses a decision that is already `fallback: true`.
108
153
  Exit 1 on a broken config or knowledge file. A missing key is not a crash: the message is handed off with
109
154
  `model:config`.
110
155
 
@@ -119,7 +164,10 @@ Every handoff row in `handoffs.jsonl` holds the customer's own message, which ca
119
164
  number or a card number. It stays until it is pruned. Choose a retention the product's privacy policy states, at most
120
165
  30 days for an agent that answers people (the standard's A1). A1 also asks for the text redacted: the outbox keeps it
121
166
  as written, and `prune` takes any `--keep-days`, so such an agent passes an `on_handoff` that redacts what it stores,
122
- which replaces the outbox, and keeps 30 days or fewer. Prune on a schedule:
167
+ which replaces the outbox, and keeps 30 days or fewer. A refused `draft`, which can echo what the customer wrote,
168
+ reaches the returned decision and an `on_handoff` the agent passes, never a file: neither `decisions.jsonl` nor the
169
+ default outbox stores it. A capability handler returns the decision without it: a kit job's result is written to
170
+ the job's file (`fabric_interop`'s job store), so pass `draft` only to the repair step. Prune on a schedule:
123
171
 
124
172
  ```bash
125
173
  python3 support_agent.py prune --config config.json --keep-days 30