@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,340 @@
1
+ // The project board's wire (fabric-project-comms/0.1, DEC-0022, DEC-0073, DEC-0077): constants, refusal codes, the request digest, the closed shapes the kit reads back, evidence.
2
+ // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_shapes.py. Node 20+, no dependency.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import { sameIdentifier } from './fabric-identifiers.mjs';
6
+
7
+ // A service agent answers a Project's requests on Fabric's project board (fabric-project-comms/0.1; contract DEC-0022,
8
+ // DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149; FAA-12b, FAA-40). The twin of fabric_board.py — the same steps, the
9
+ // same checkpoint file, the same answers; Node built-ins only. fabric-hub.mjs finds the hub.
10
+ //
11
+ // enroll (capability) → claim → read the message → accept (the exact digest) → [effectBegin] → [progress] →
12
+ // [reply] → complete ({state, observed, summary?, evidence ≤ 8 sha256 artifacts})
13
+ //
14
+ // Responder runs that loop inside the service process: one tick every 15 s ± 5 s, claiming when idle and renewing the
15
+ // 60 s lease (at about 20 s) while work runs. Before it advances a step it writes and fsyncs a checkpoint; a restart
16
+ // reconciles work whose outcome is unknown (com.reconcile) and takes accepted work back by com.responder_replace with a
17
+ // transfer (DEC-0078's receipt member transferred; a Fabric that does not answer it leaves the work held — never claimed
18
+ // back). It stops acting on fenced, lease_expired and authority_changed.
19
+ //
20
+ // THE REQUEST BODY IS DATA. admit(request) is the service's own input gate, required: null admits, a sentence refuses
21
+ // (the request is accepted and completed failed_known with it). Whatever the handler does with an admitted body — and
22
+ // the checks it needs before a model, a tool or a spend sees it — is the service's own responsibility.
23
+ //
24
+ // A board session is served the respond scope only (RESPOND_TOOLS). com.submit, com.cancel, com.read_ack and
25
+ // com.directory are never sent through one; findProject checks a directory page a Project session read (FAC-SEM-107).
26
+ export const COMMS_PROTOCOL = 'fabric-project-comms/0.1';
27
+
28
+ export const LEASE_SECONDS = 60;
29
+
30
+ export const SUMMARY_MAX = 2000;
31
+
32
+ export const EVIDENCE_MAX = 8;
33
+
34
+ export const RESPOND_TOOLS = Object.freeze(new Set(['com.status', 'com.list', 'com.get', 'com.reply', 'com.enroll', 'com.claim', 'com.renew',
35
+ 'com.accept', 'com.progress', 'com.effect_begin', 'com.complete', 'com.reconcile', 'com.responder_replace']));
36
+
37
+ export const REFUSAL_CODES = Object.freeze(new Set(['invalid_arguments', 'body_too_large', 'not_authorized', 'authority_changed',
38
+ 'unsupported_capability', 'unsupported_operation', 'not_available', 'idempotency_conflict', 'idempotency_window_expired',
39
+ 'capacity_exceeded', 'digest_conflict', 'revision_conflict', 'fenced', 'lease_expired', 'invalid_transition', 'reconcile_required',
40
+ 'observation_required', 'cursor_reset_required', 'immutable_participants', 'held']));
41
+
42
+ export const REQUEST_STATES = ['queued', 'claimed', 'accepted', 'in_progress', 'completed', 'failed_known', 'cancelled', 'expired', 'outcome_unknown'];
43
+
44
+ const EFFECT_STATES = ['not_started', 'started', 'succeeded_observed', 'failed_observed', 'unknown'];
45
+
46
+ /**
47
+ * DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
48
+ * `held`: it settles, reports on or keeps alive started work, cancels or reads. The contract's HOLD_SCOPES.
49
+ */
50
+ export const HOLD_SCOPES = Object.freeze({ 'com.submit': ['thread'], 'com.reply': ['thread'], 'com.enroll': ['project', 'slot'],
51
+ 'com.responder_replace': ['project', 'slot'], 'com.claim': ['thread', 'project', 'slot'], 'com.accept': ['thread', 'project', 'slot'],
52
+ 'com.effect_begin': ['thread', 'project', 'slot'] });
53
+
54
+ const OPAQUE = /^[A-Za-z0-9_-]{8,128}$/;
55
+
56
+ const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
57
+
58
+ export const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
59
+
60
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
61
+
62
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
63
+
64
+ const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
65
+
66
+ /** A board refusal (a contract refusal code) or a kit fault — see fabric_board.py's BoardError for the kit's codes. */
67
+ export class BoardError extends Error {
68
+ constructor(code, message, retryable = false, op = undefined, data = {}) {
69
+ super(message);
70
+ this.name = 'BoardError';
71
+ Object.assign(this, { code, retryable, op, data });
72
+ }
73
+ }
74
+
75
+ /** Thrown inside a handler (from ctx.progress / ctx.beginEffect) when the attempt is no longer this responder's. */
76
+ export class Stopped extends Error {
77
+ constructor() {
78
+ super('the attempt is no longer this responder\'s');
79
+ this.name = 'Stopped';
80
+ }
81
+ }
82
+
83
+ const LONE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
84
+
85
+ /** RFC 8785 as the board takes it: keys by UTF-16 code unit, integers only (|n| ≤ 2^53−1), no lone surrogate. */
86
+ export function commsCanonical(value) {
87
+ if (value === null || typeof value === 'boolean') return JSON.stringify(value);
88
+ if (typeof value === 'number') {
89
+ if (!Number.isSafeInteger(value)) throw new RangeError('canonical JSON admits integers within ±(2^53−1) only');
90
+ return String(value === 0 ? 0 : value);
91
+ }
92
+ if (typeof value === 'string') {
93
+ if (LONE.test(value)) throw new RangeError('canonical JSON refuses a lone surrogate');
94
+ return JSON.stringify(value);
95
+ }
96
+ if (Array.isArray(value)) return `[${value.map(commsCanonical).join(',')}]`;
97
+ if (typeof value === 'object') {
98
+ const keys = Object.keys(value);
99
+ for (const k of keys) commsCanonical(k);
100
+ return `{${keys.sort().map((k) => {
101
+ if (value[k] === undefined) throw new RangeError('canonical JSON has no undefined');
102
+ return `${commsCanonical(k)}:${commsCanonical(value[k])}`;
103
+ }).join(',')}}`;
104
+ }
105
+ throw new RangeError(`canonical JSON has no ${typeof value}`);
106
+ }
107
+
108
+ /** sha256:<hex> over `fabric-project-comms/0.1 NUL <operation> NUL <canonical JSON>` (the contract's commsDigest). */
109
+ export function commsDigest(operation, value) {
110
+ if (typeof operation !== 'string' || operation === '' || operation.includes('\u0000')) throw new RangeError('an operation is a non-empty name without NUL');
111
+ return `sha256:${crypto.createHash('sha256').update(`${COMMS_PROTOCOL}\u0000${operation}\u0000${commsCanonical(value)}`, 'utf8').digest('hex')}`;
112
+ }
113
+
114
+ export const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
115
+
116
+ export const isInt = (v, min) => Number.isSafeInteger(v) && v >= min;
117
+
118
+ const str = (v, re) => typeof v === 'string' && re.test(v);
119
+
120
+ function closed(value, at, required, allowed, problems) {
121
+ if (!isObj(value)) {
122
+ problems.push(`${at} is an object`);
123
+ return false;
124
+ }
125
+ for (const k of required) if (!(k in value)) problems.push(`${at}/${k} is required`);
126
+ for (const k of Object.keys(value)) if (!allowed.includes(k)) problems.push(`${at}/${k} is not a member the contract names`);
127
+ return true;
128
+ }
129
+
130
+ function attemptProblems(a, at, problems) {
131
+ const keys = ['id', 'generation', 'digest', 'leaseExpiresAt'];
132
+ if (!closed(a, at, keys, keys, problems)) return;
133
+ if (!str(a.id, OPAQUE)) problems.push(`${at}/id is a board id`);
134
+ if (!isInt(a.generation, 1)) problems.push(`${at}/generation is an integer ≥ 1`);
135
+ if (!str(a.digest, SHA256)) problems.push(`${at}/digest is sha256:<hex>`);
136
+ if (!str(a.leaseExpiresAt, TIMESTAMP)) problems.push(`${at}/leaseExpiresAt is a timestamp`);
137
+ }
138
+
139
+ /** comms-receipt at the pin, closed at every level; transferred answers com.responder_replace only, each request once. */
140
+ export function receiptProblems(value, op) {
141
+ const problems = [];
142
+ const allowed = ['ok', 'repeated', 'message', 'thread', 'at', 'request', 'slot', 'attempt', 'transferred'];
143
+ if (!closed(value, '', ['ok', 'repeated'], allowed, problems)) return problems;
144
+ if (value.ok !== true) problems.push('/ok is true');
145
+ if (typeof value.repeated !== 'boolean') problems.push('/repeated is a boolean');
146
+ for (const k of ['message', 'thread']) if (k in value && !str(value[k], OPAQUE)) problems.push(`/${k} is a board id`);
147
+ if ('at' in value && !str(value.at, TIMESTAMP)) problems.push('/at is a timestamp');
148
+ if ('request' in value) {
149
+ const r = value.request;
150
+ const keys = ['id', 'state', 'effect', 'revision'];
151
+ if (closed(r, '/request', keys, [...keys, 'settledBy'], problems)) {
152
+ if (!str(r.id, OPAQUE)) problems.push('/request/id is a board id');
153
+ if (!REQUEST_STATES.includes(r.state)) problems.push('/request/state is a request state');
154
+ if (!EFFECT_STATES.includes(r.effect)) problems.push('/request/effect is an effect state');
155
+ if (!isInt(r.revision, 1)) problems.push('/request/revision is an integer ≥ 1');
156
+ if ('settledBy' in r) {
157
+ // DEC-0081: present only when the estate owner settled it; absent, the responder did.
158
+ if (r.settledBy !== 'owner') problems.push('/request/settledBy is owner');
159
+ if (!['completed', 'failed_known'].includes(r.state)) problems.push('/request/settledBy names a settled request (completed or failed_known)');
160
+ }
161
+ }
162
+ }
163
+ if ('slot' in value) {
164
+ const s = value.slot;
165
+ const keys = ['project', 'capability', 'generation', 'leaseExpiresAt'];
166
+ if (closed(s, '/slot', keys, keys, problems)) {
167
+ if (!str(s.project, PROJECT)) problems.push('/slot/project is a Project id');
168
+ if (!str(s.capability, CAPABILITY)) problems.push('/slot/capability is a capability name');
169
+ if (!isInt(s.generation, 1)) problems.push('/slot/generation is an integer ≥ 1');
170
+ if (!str(s.leaseExpiresAt, TIMESTAMP)) problems.push('/slot/leaseExpiresAt is a timestamp');
171
+ }
172
+ }
173
+ if ('attempt' in value) attemptProblems(value.attempt, '/attempt', problems);
174
+ if ('transferred' in value) {
175
+ const t = value.transferred;
176
+ if (op !== undefined && op !== 'com.responder_replace') problems.push('/transferred answers com.responder_replace only (DEC-0078)');
177
+ if (!Array.isArray(t) || t.length < 1 || t.length > 64) problems.push('/transferred is 1 to 64 items');
178
+ else {
179
+ const seen = new Set();
180
+ t.forEach((item, i) => {
181
+ const at = `/transferred/${i}`;
182
+ const keys = ['request', 'revision', 'attempt'];
183
+ if (!closed(item, at, keys, keys, problems)) return;
184
+ if (!str(item.request, OPAQUE)) problems.push(`${at}/request is a board id`);
185
+ else if (seen.has(item.request)) problems.push(`${at}/request names a request already listed (each request once, DEC-0078)`);
186
+ seen.add(item.request);
187
+ if (!isInt(item.revision, 1)) problems.push(`${at}/revision is an integer ≥ 1`);
188
+ attemptProblems(item.attempt, `${at}/attempt`, problems);
189
+ });
190
+ }
191
+ }
192
+ return problems;
193
+ }
194
+
195
+ /** comms-refusal: {error: {code, message, retryable}}, closed, a known code, one sentence ≤ 300. */
196
+ export function refusalProblems(value) {
197
+ const problems = [];
198
+ if (closed(value, '', ['error'], ['error'], problems)) {
199
+ const e = value.error;
200
+ const keys = ['code', 'message', 'retryable'];
201
+ if (closed(e, '/error', keys, keys, problems)) {
202
+ if (!REFUSAL_CODES.has(e.code)) problems.push('/error/code is a refusal code the contract names');
203
+ if (typeof e.message !== 'string' || e.message.length < 1 || e.message.length > 300) problems.push('/error/message is 1 to 300 characters');
204
+ if (typeof e.retryable !== 'boolean') problems.push('/error/retryable is a boolean');
205
+ else if (e.code === 'held' && e.retryable !== true) problems.push('/error/retryable is true for held: the operator lifts a hold (DEC-0079)');
206
+ }
207
+ }
208
+ return problems;
209
+ }
210
+
211
+ /** FAC-SEM-107 (DEC-0077), the contract's commsDirectory: compared exactly as written; never throws. */
212
+ export function directoryFindings(page) {
213
+ const findings = [];
214
+ const projects = isObj(page) && Array.isArray(page.projects) ? page.projects : [];
215
+ const seen = new Set();
216
+ projects.forEach((project, p) => {
217
+ if (!isObj(project)) return;
218
+ if (typeof project.id === 'string') {
219
+ if (seen.has(project.id)) findings.push({ code: 'FAC-SEM-107', instancePath: `/projects/${p}/id`, message: `a directory page lists each Project once: ${project.id} is listed again` });
220
+ seen.add(project.id);
221
+ }
222
+ const names = new Set();
223
+ (Array.isArray(project.capabilities) ? project.capabilities : []).forEach((entry, c) => {
224
+ if (!isObj(entry) || typeof entry.capability !== 'string') return;
225
+ if (names.has(entry.capability)) findings.push({ code: 'FAC-SEM-107', instancePath: `/projects/${p}/capabilities/${c}/capability`, message: `a Project lists each capability once: ${entry.capability} is listed again` });
226
+ names.add(entry.capability);
227
+ });
228
+ });
229
+ return findings;
230
+ }
231
+
232
+ /** comms-directory, closed at every level, then FAC-SEM-107. */
233
+ export function directoryProblems(page) {
234
+ const problems = [];
235
+ const top = ['protocol', 'snapshot', 'projects', 'cursor'];
236
+ if (!closed(page, '', top, top, problems)) return problems;
237
+ if (page.protocol !== COMMS_PROTOCOL) problems.push(`/protocol is ${COMMS_PROTOCOL}`);
238
+ if (closed(page.snapshot, '/snapshot', ['readAt'], ['readAt', 'stale'], problems)) {
239
+ if (!str(page.snapshot.readAt, TIMESTAMP)) problems.push('/snapshot/readAt is a timestamp');
240
+ if ('stale' in page.snapshot && typeof page.snapshot.stale !== 'boolean') problems.push('/snapshot/stale is a boolean');
241
+ }
242
+ if (page.cursor !== null && !str(page.cursor, OPAQUE)) problems.push('/cursor is a board id or null');
243
+ if (!Array.isArray(page.projects) || page.projects.length > 100) return [...problems, '/projects is at most 100 Projects'];
244
+ page.projects.forEach((project, p) => {
245
+ const at = `/projects/${p}`;
246
+ if (!closed(project, at, ['id', 'name', 'capabilities'], ['id', 'name', 'capabilities'], problems)) return;
247
+ if (!str(project.id, PROJECT)) problems.push(`${at}/id is a Project id`);
248
+ if (typeof project.name !== 'string' || project.name.length < 1 || project.name.length > 120) problems.push(`${at}/name is 1 to 120 characters`);
249
+ if (!Array.isArray(project.capabilities) || project.capabilities.length > 64) {
250
+ problems.push(`${at}/capabilities is at most 64 entries`);
251
+ return;
252
+ }
253
+ project.capabilities.forEach((entry, c) => {
254
+ const cat = `${at}/capabilities/${c}`;
255
+ if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder', 'held'], problems)) return;
256
+ if (!str(entry.capability, CAPABILITY)) problems.push(`${cat}/capability is a capability name`);
257
+ if (!['active', 'stale', 'absent'].includes(entry.responder)) problems.push(`${cat}/responder is active, stale or absent`);
258
+ if ('held' in entry && typeof entry.held !== 'boolean') problems.push(`${cat}/held is a boolean (DEC-0079)`);
259
+ });
260
+ });
261
+ return [...problems, ...directoryFindings(page).map((f) => `${f.instancePath} ${f.message}`)];
262
+ }
263
+
264
+ /**
265
+ * DEC-0079, the contract's holdRefuses: true when a board refuses `tool` on `target` with `held` under `holds`. `target`
266
+ * is what the board resolved ({thread, project, capability, fenced, repeat}); `holds` is {threads, projects, slots:
267
+ * [{project, capability}]}. Ids compare as written; an inherited or mistyped member counts as absent; never throws.
268
+ */
269
+ export function holdRefuses(tool, target, holds) {
270
+ const own = (value, key) => (isObj(value) && Object.hasOwn(value, key) ? value[key] : undefined);
271
+ const scopes = typeof tool === 'string' && Object.hasOwn(HOLD_SCOPES, tool) ? HOLD_SCOPES[tool] : undefined;
272
+ if (!scopes || !isObj(target)) return false;
273
+ if (own(target, 'repeat') === true) return false;
274
+ if (tool === 'com.reply' && own(target, 'fenced') === true) return false;
275
+ const text = (v) => (typeof v === 'string' ? v : undefined);
276
+ const strings = (v) => new Set(Array.isArray(v) ? v.filter((item) => typeof item === 'string') : []);
277
+ const thread = text(own(target, 'thread'));
278
+ const project = text(own(target, 'project'));
279
+ const capability = text(own(target, 'capability'));
280
+ if (scopes.includes('thread') && thread !== undefined && strings(own(holds, 'threads')).has(thread)) return true;
281
+ if (scopes.includes('project') && project !== undefined && strings(own(holds, 'projects')).has(project)) return true;
282
+ const slots = own(holds, 'slots');
283
+ if (scopes.includes('slot') && project !== undefined && capability !== undefined && Array.isArray(slots)) {
284
+ return slots.some((slot) => own(slot, 'project') === project && own(slot, 'capability') === capability);
285
+ }
286
+ return false;
287
+ }
288
+
289
+ /** Look a Project up in one com.directory page a Project session read; the page is checked first (FAC-SEM-107). */
290
+ export function findProject(page, projectId, capability) {
291
+ const problems = directoryProblems(page);
292
+ if (problems.length) throw new BoardError('directory-invalid', `The directory page cannot be read: ${problems.slice(0, 3).join('; ')}.`);
293
+ const entry = page.projects.find((p) => p.id === projectId);
294
+ if (!entry) return null;
295
+ return capability === undefined || entry.capabilities.some((c) => c.capability === capability) ? entry : null;
296
+ }
297
+
298
+ /** One artifact reference {id, contentHash, label?}; the hash computed here from `content` or the file at `file`. */
299
+ export function artifactRef(uri, { content, file, label } = {}) {
300
+ if ((content === undefined) === (file === undefined)) throw new BoardError('evidence', 'Give the artifact\'s bytes or its path, one of them.');
301
+ const hash = crypto.createHash('sha256').update(content !== undefined ? content : fs.readFileSync(file)).digest('hex');
302
+ const ref = { id: uri, contentHash: `sha256:${hash}`, ...(label !== undefined ? { label } : {}) };
303
+ const problems = evidenceProblems([ref]);
304
+ if (problems.length) throw new BoardError('evidence', problems.join('; '));
305
+ return ref;
306
+ }
307
+
308
+ /** At most 8 references, closed, and no artifact twice: two ids with one normal form (DEC-0076) name one artifact. */
309
+ export function evidenceProblems(evidence) {
310
+ if (!Array.isArray(evidence) || evidence.length > EVIDENCE_MAX) return [`evidence is a list of at most ${EVIDENCE_MAX} artifact references`];
311
+ const problems = [];
312
+ evidence.forEach((ref, i) => {
313
+ const at = `evidence/${i}`;
314
+ if (!closed(ref, at, ['id', 'contentHash'], ['id', 'contentHash', 'label'], problems)) return;
315
+ if (!str(ref.id, URI)) problems.push(`${at}/id is a URI`);
316
+ if (!str(ref.contentHash, SHA256)) problems.push(`${at}/contentHash is sha256:<hex>`);
317
+ if ('label' in ref && (typeof ref.label !== 'string' || ref.label.length > 120)) problems.push(`${at}/label is at most 120 characters`);
318
+ for (let j = 0; j < i; j += 1) if (isObj(evidence[j]) && sameIdentifier(evidence[j].id, ref.id)) problems.push(`${at}/id names the artifact evidence/${j} already names`);
319
+ });
320
+ return problems;
321
+ }
322
+
323
+ /** comms-complete's outcome plus the kit's optional reply {text, format?, artifacts?}. */
324
+ export function outcomeProblems(outcome, effectBegan) {
325
+ const problems = [];
326
+ if (!closed(outcome, 'outcome', ['state'], ['state', 'observed', 'summary', 'evidence', 'reply'], problems)) return problems;
327
+ if (!['completed', 'failed_known'].includes(outcome.state)) problems.push('outcome/state is completed or failed_known');
328
+ const observed = 'observed' in outcome ? outcome.observed : !effectBegan;
329
+ if (typeof observed !== 'boolean') problems.push('outcome/observed is a boolean');
330
+ else if (effectBegan && observed !== true) problems.push('observation_required: after an effect began only an observed result settles the request');
331
+ if ('summary' in outcome && (typeof outcome.summary !== 'string' || outcome.summary.length < 1 || outcome.summary.length > SUMMARY_MAX)) problems.push(`outcome/summary is 1 to ${SUMMARY_MAX} characters`);
332
+ if ('evidence' in outcome) problems.push(...evidenceProblems(outcome.evidence));
333
+ if ('reply' in outcome && closed(outcome.reply, 'outcome/reply', ['text'], ['text', 'format', 'artifacts'], problems)) {
334
+ const { text } = outcome.reply;
335
+ if (typeof text !== 'string' || !text || Buffer.byteLength(text, 'utf8') > 65536) problems.push('outcome/reply/text is 1 to 65,536 UTF-8 bytes');
336
+ if ('format' in outcome.reply && !['text/plain', 'text/markdown'].includes(outcome.reply.format)) problems.push('outcome/reply/format is text/plain or text/markdown');
337
+ if ('artifacts' in outcome.reply) problems.push(...evidenceProblems(outcome.reply.artifacts).map((p) => p.replace('evidence', 'outcome/reply/artifacts')));
338
+ }
339
+ return problems;
340
+ }