@passioncode-ai/passioncode 0.2.17 → 0.2.19

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 (117) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -13
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  57. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
  62. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
  67. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  69. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  72. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  74. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  77. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  78. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  79. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  80. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
  81. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  82. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  83. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  84. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  85. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  86. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  87. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  88. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  89. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  90. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  91. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
  92. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  93. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  94. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  95. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  96. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
  97. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  98. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  99. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  100. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  101. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  102. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
  103. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
  104. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
  105. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  106. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
  107. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
  108. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
  109. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
  110. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  111. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  112. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  113. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  114. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  115. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  116. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
  117. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,327 @@
1
+ // Settings backups (DEC-0025, FAC-SEM-035): canonical form, digest, write, list, check, restore.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_backups.py. Node 20+, no dependency.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { ID, INSTANCE, ServiceError } from './fabric-base.mjs';
8
+ import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
9
+ import { scalar } from './fabric-protocols.mjs';
10
+ import { isPlainObject, isTimestamp } from './fabric-usage.mjs';
11
+
12
+ // The twins of fabric_service.make_settings_backup and the rest; both kits write byte-identical files.
13
+ export const SETTINGS_BACKUP_FORMAT = 'fabric-settings-backup/1';
14
+
15
+ export const SETTINGS_BACKUP_KEEP = 14;
16
+
17
+ export const SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60;
18
+
19
+ const TABLE = /^[a-z][a-z0-9_]{0,62}$/;
20
+
21
+ const SECRET_TABLE = /^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$/;
22
+
23
+ const COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
24
+
25
+ const SECRET_COLUMN = /^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$/;
26
+
27
+ const REASON = /^[a-z][a-z0-9_.-]{0,63}$/;
28
+
29
+ const SHA256 = /^[0-9a-f]{64}$/;
30
+
31
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
32
+
33
+ export const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
34
+
35
+ const MAX_CELL_CHARS = 1048576;
36
+
37
+ const MAX_ROW_VALUES = 512;
38
+
39
+ const STALE_TEMP_MS = 60 * 60 * 1000;
40
+
41
+ const BACKUP_NAME = /^settings-[0-9]{8}T[0-9]{12}Z\.json$/;
42
+
43
+ function byCodePoint(a, b) {
44
+ const x = Array.from(a), y = Array.from(b);
45
+ for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
46
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
47
+ if (d !== 0) return d;
48
+ }
49
+ return x.length - y.length;
50
+ }
51
+
52
+ /**
53
+ * The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, `", "` and `": "`,
54
+ * strings escaped as JSON requires and nothing else, integers in decimal — Python's
55
+ * `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
56
+ */
57
+ /** CT-25 (DEC-0052): a lone surrogate in a string or a key has no canonical form — Python cannot encode it as UTF-8. */
58
+ function settingsString(text) {
59
+ if (/\p{Surrogate}/u.test(text)) throw new ServiceError('a lone surrogate has no canonical form');
60
+ return JSON.stringify(text);
61
+ }
62
+
63
+ export function settingsCanonicalJson(value) {
64
+ if (value === null) return 'null';
65
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
66
+ if (typeof value === 'number') {
67
+ if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
68
+ return String(value);
69
+ }
70
+ if (typeof value === 'string') return settingsString(value);
71
+ if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
72
+ if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
73
+ return `{${Object.keys(value).sort(byCodePoint).map((key) => `${settingsString(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
74
+ }
75
+ throw new ServiceError(`${typeof value} is not a JSON value`);
76
+ }
77
+
78
+ /** The `sha256` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of `tables`. */
79
+ export const settingsBackupDigest = (tables) => crypto.createHash('sha256').update(settingsCanonicalJson(tables), 'utf8').digest('hex');
80
+
81
+ /** Why a reader refuses this settings backup: its shape and FAC-SEM-035. Empty means intact. */
82
+ export function settingsBackupProblems(doc) {
83
+ if (!isPlainObject(doc)) return ['a settings backup is a JSON object'];
84
+ const problems = [];
85
+ if (doc.format !== SETTINGS_BACKUP_FORMAT) problems.push(`format must be ${SETTINGS_BACKUP_FORMAT}, not ${JSON.stringify(doc.format ?? null)}: a reader refuses a format it does not know`);
86
+ for (const key of ['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables']) if (!(key in doc)) problems.push(`missing ${key}`);
87
+ for (const key of Object.keys(doc)) if (!['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables', 'extensions'].includes(key)) problems.push(`${key} is not a settings backup field`);
88
+ if ('extensions' in doc) {
89
+ if (!isPlainObject(doc.extensions)) problems.push('extensions is an object');
90
+ else for (const key of Object.keys(doc.extensions)) if (!URI.test(key)) problems.push(`extensions key ${JSON.stringify(key)} is not a URI`);
91
+ }
92
+ if ('service' in doc) {
93
+ const s = doc.service;
94
+ if (!isPlainObject(s) || Object.keys(s).sort().join() !== 'id,instance,schema,version') problems.push('service is {id, instance, version, schema}');
95
+ else {
96
+ if (!ID.test(String(s.id))) problems.push(`service.id must match ${ID.source}`);
97
+ if (!INSTANCE.test(String(s.instance))) problems.push(`service.instance must match ${INSTANCE.source}`);
98
+ if (!(typeof s.version === 'string' && s.version.length <= 64)) problems.push('service.version is a string of at most 64 characters');
99
+ if (!((Number.isInteger(s.schema) && s.schema >= 0) || (typeof s.schema === 'string' && s.schema.length >= 1 && s.schema.length <= 64))) problems.push('service.schema is a whole number or a short string');
100
+ }
101
+ }
102
+ if ('created_at' in doc && !isTimestamp(doc.created_at)) problems.push('created_at is a UTC timestamp');
103
+ if ('reason' in doc && !(typeof doc.reason === 'string' && REASON.test(doc.reason))) problems.push(`reason must match ${REASON.source}`);
104
+ if ('sha256' in doc && !(typeof doc.sha256 === 'string' && SHA256.test(doc.sha256))) problems.push('sha256 is 64 lowercase hex digits');
105
+ const tables = isPlainObject(doc.tables) ? doc.tables : {};
106
+ const counts = isPlainObject(doc.counts) ? doc.counts : {};
107
+ if ('tables' in doc && !isPlainObject(doc.tables)) problems.push('tables is an object');
108
+ if ('counts' in doc && !isPlainObject(doc.counts)) problems.push('counts is an object');
109
+ let canonical = true;
110
+ for (const name of [...Object.keys(tables), ...Object.keys(counts).filter((n) => !Object.hasOwn(tables, n))]) {
111
+ if (LONE_SURROGATE.test(name)) canonical = false;
112
+ if (!TABLE.test(name) || SECRET_TABLE.test(name)) problems.push(`table ${name} is never backed up: it can only hold connections, tokens, secrets, credentials, passwords, login codes or sessions (or its name is not a table name)`);
113
+ }
114
+ for (const [name, count] of Object.entries(counts)) if (!(Number.isInteger(count) && count >= 0)) problems.push(`counts.${name} is a whole number`);
115
+ for (const [name, table] of Object.entries(tables)) {
116
+ if (!isPlainObject(table) || Object.keys(table).sort().join() !== 'columns,rows') { problems.push(`tables.${name} is {columns, rows}`); continue; }
117
+ let columns = table.columns;
118
+ if (!(Array.isArray(columns) && columns.length >= 1 && columns.length <= 512 && new Set(columns.map(String)).size === columns.length)) {
119
+ problems.push(`tables.${name}.columns is 1 to 512 distinct names`);
120
+ if (!Array.isArray(columns)) columns = [];
121
+ }
122
+ for (const column of columns) {
123
+ if (!(typeof column === 'string' && COLUMN.test(column)) || SECRET_COLUMN.test(String(column))) problems.push(`column ${name}.${column} is refused: its name says it holds a credential value (name the secret, never store it: secret_name, token_slot)`);
124
+ }
125
+ if (!Array.isArray(table.rows)) { problems.push(`tables.${name}.rows is a list`); continue; }
126
+ table.rows.forEach((row, i) => {
127
+ const at = `/tables/${name}/rows/${i}`;
128
+ if (!Array.isArray(row)) { problems.push(`${at} is a list of values`); return; }
129
+ if (row.length !== columns.length) problems.push(`FAC-SEM-035 ${at}: row has ${row.length} values for ${columns.length} columns`);
130
+ if (row.length > MAX_ROW_VALUES) problems.push(`${at} holds more than ${MAX_ROW_VALUES} values`);
131
+ row.forEach((cell, j) => {
132
+ if (typeof cell === 'number' && !Number.isSafeInteger(cell)) {
133
+ canonical = false;
134
+ problems.push(`FAC-SEM-035 ${at}/${j}: a fraction or an unsafe integer has no canonical form; store it as a string`);
135
+ } else if (typeof cell === 'string' && cell.length > MAX_CELL_CHARS) {
136
+ problems.push(`${at}/${j} is longer than ${MAX_CELL_CHARS} characters`);
137
+ } else if (typeof cell === 'string' && LONE_SURROGATE.test(cell)) {
138
+ canonical = false;
139
+ problems.push(`FAC-SEM-035 ${at}/${j}: a string with a lone surrogate cannot be encoded as UTF-8`);
140
+ } else if (!(cell === null || ['boolean', 'number', 'string'].includes(typeof cell))) {
141
+ canonical = false;
142
+ problems.push(`${at}/${j} is not a JSON scalar`);
143
+ }
144
+ });
145
+ });
146
+ if (counts[name] !== table.rows.length) problems.push(`FAC-SEM-035 /counts/${name}: counts says ${counts[name] ?? 'nothing'} for ${table.rows.length} rows`);
147
+ }
148
+ for (const name of Object.keys(counts)) if (!Object.hasOwn(tables, name)) problems.push(`FAC-SEM-035 /counts/${name}: counts names ${name}, which the backup does not carry`);
149
+ if (canonical && isPlainObject(doc.tables)) {
150
+ let digest = null;
151
+ try {
152
+ digest = settingsBackupDigest(tables);
153
+ } catch (error) {
154
+ problems.push(`FAC-SEM-035 /tables: the tables have no canonical form (${error.message})`);
155
+ }
156
+ if (digest !== null && doc.sha256 !== digest) problems.push('FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it');
157
+ }
158
+ return problems;
159
+ }
160
+
161
+ /** Where every service keeps its settings backups — outside its data directory (DEC-0025). */
162
+ export function settingsBackupRoot(platform = process.platform) {
163
+ if (platform === 'darwin') return path.join(os.homedir(), 'Library/Application Support/PassionCode');
164
+ if (platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'PassionCode');
165
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'PassionCode');
166
+ }
167
+
168
+ export function settingsBackupDir(id, instance = 'default', root = settingsBackupRoot()) {
169
+ if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
170
+ if (!INSTANCE.test(instance)) throw new ServiceError(`instance must match ${INSTANCE}`);
171
+ return path.join(root, 'settings-backups', id, instance);
172
+ }
173
+
174
+ const microStamp = (ms) => `${new Date(ms).toISOString().slice(0, 23)}000Z`;
175
+
176
+ /** A snapshot of the operator's decisions and bindings (the tables on the service's allow-list). Refuses a file a reader would refuse. */
177
+ export function makeSettingsBackup({ id, instance = 'default', version, schema, tables, reason, now = Date.now() }) {
178
+ const doc = {
179
+ format: SETTINGS_BACKUP_FORMAT, service: { id, instance, version, schema }, created_at: microStamp(now), reason, sha256: '',
180
+ counts: Object.fromEntries(Object.entries(tables).map(([name, table]) => [name, (table?.rows ?? []).length])), tables,
181
+ };
182
+ try {
183
+ doc.sha256 = settingsBackupDigest(tables);
184
+ } catch (error) {
185
+ throw new ServiceError(`This snapshot has no canonical form: ${error.message}`);
186
+ }
187
+ const problems = settingsBackupProblems(doc);
188
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
189
+ return doc;
190
+ }
191
+
192
+ const backupName = (doc) => `settings-${doc.created_at.replace(/[-:]/g, '').replace(/\.(\d+)Z$/, (_, f) => `${(f + '000000').slice(0, 6)}Z`)}.json`;
193
+
194
+ /** The snapshot files, newest first. */
195
+ export function settingsBackups(dir) {
196
+ if (!fs.existsSync(dir)) return [];
197
+ return fs.readdirSync(dir).filter((n) => BACKUP_NAME.test(n)).sort().reverse().map((n) => path.join(dir, n));
198
+ }
199
+
200
+ /** The first number written with a fraction or an exponent outside a string (`1.0`, `1e2`); JSON.parse would read
201
+ * `1.0` as the integer 1, where the Python reader — and the canonical form — sees a fraction. */
202
+ function fractionLexeme(text) {
203
+ let inString = false;
204
+ for (let i = 0; i < text.length; i += 1) {
205
+ const c = text[i];
206
+ if (inString) {
207
+ if (c === '\\') i += 1;
208
+ else if (c === '"') inString = false;
209
+ } else if (c === '"') {
210
+ inString = true;
211
+ } else if (c === '-' || (c >= '0' && c <= '9')) {
212
+ const lexeme = text.slice(i).match(/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)[0];
213
+ if (/[.eE]/.test(lexeme)) return lexeme;
214
+ i += lexeme.length - 1;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035). */
221
+ export function readSettingsBackup(file) {
222
+ let doc;
223
+ try {
224
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file));
225
+ const fraction = fractionLexeme(text);
226
+ if (fraction) throw new Error(`${fraction} is not an integer: a fraction is stored as its decimal string`);
227
+ doc = JSON.parse(text);
228
+ } catch (error) {
229
+ throw new ServiceError(`Settings backup ${file} cannot be read: ${error.message}`);
230
+ }
231
+ const problems = settingsBackupProblems(doc);
232
+ if (problems.length) throw new ServiceError(`Settings backup ${file} is refused: ${problems.join('; ')}`);
233
+ return doc;
234
+ }
235
+
236
+ /**
237
+ * Write a snapshot: atomically, 0600 in a 0700 directory, named by its time. A temporary file an interrupted write
238
+ * left is removed first; a snapshot whose checksum equals the newest file's is not written again (null). After a
239
+ * write, all but the `keep` newest are removed (at least 14).
240
+ */
241
+ export function writeSettingsBackup(doc, dir, { keep = SETTINGS_BACKUP_KEEP } = {}) {
242
+ const problems = settingsBackupProblems(doc);
243
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
244
+ ensurePrivateDir(dir);
245
+ // Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
246
+ for (const left of fs.readdirSync(dir)) {
247
+ const at = path.join(dir, left);
248
+ try {
249
+ if (left.startsWith('.settings-') && fs.statSync(at).mtimeMs < Date.now() - STALE_TEMP_MS) fs.rmSync(at, { force: true });
250
+ } catch { /* gone meanwhile */ }
251
+ }
252
+ const existing = settingsBackups(dir);
253
+ if (existing.length) {
254
+ try {
255
+ if (readSettingsBackup(existing[0]).sha256 === doc.sha256) return null;
256
+ } catch { /* a damaged newest file is no reason to skip a good snapshot */ }
257
+ }
258
+ const file = path.join(dir, backupName(doc));
259
+ atomicWrite(file, `${JSON.stringify(doc, null, 2)}\n`, 0o600);
260
+ // The newest by name, but never the one just written: a clock that stepped back must not delete it.
261
+ for (const old of settingsBackups(dir).filter((f) => f !== file).slice(Math.max(keep, SETTINGS_BACKUP_KEEP) - 1)) fs.rmSync(old, { force: true });
262
+ return file;
263
+ }
264
+
265
+ /** True when there is no snapshot or the newest is older than 24 hours. */
266
+ export function backupDue(dir, now = Date.now()) {
267
+ const newest = settingsBackups(dir)[0];
268
+ if (!newest) return true;
269
+ const m = path.basename(newest).match(/^settings-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})\d{3}Z\.json$/);
270
+ // A name no calendar has: take a fresh snapshot rather than trust it (Date.UTC would roll it over).
271
+ if (!isTimestamp(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)) return true;
272
+ const at = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
273
+ return (now - at) / 1000 > SETTINGS_BACKUP_MAX_AGE_SECONDS;
274
+ }
275
+
276
+ /** The source of an automatic restore: the newest intact snapshot with content; damaged ones are returned with the reason. */
277
+ export function newestIntactBackup(dir) {
278
+ const skipped = [];
279
+ for (const file of settingsBackups(dir)) {
280
+ let doc;
281
+ try {
282
+ doc = readSettingsBackup(file);
283
+ } catch (error) {
284
+ skipped.push({ file, reason: error.message });
285
+ continue;
286
+ }
287
+ if (Object.values(doc.counts).some((n) => n > 0)) return { file, doc, skipped };
288
+ }
289
+ return { file: null, doc: null, skipped };
290
+ }
291
+
292
+ /**
293
+ * What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
294
+ * required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
295
+ */
296
+ export function restorePlan(doc, current) {
297
+ const problems = settingsBackupProblems(doc);
298
+ if (problems.length) throw new ServiceError(`A damaged snapshot is never restored: ${problems.join('; ')}`);
299
+ const plan = { source: { created_at: doc.created_at, sha256: doc.sha256 }, tables: {}, unknownTables: [] };
300
+ for (const [name, table] of Object.entries(doc.tables)) {
301
+ const now = current[name];
302
+ if (!now) { plan.unknownTables.push(name); continue; }
303
+ const columns = now.columns ?? [], key = now.key ?? [], required = now.required ?? [];
304
+ const existing = new Set((now.existing ?? []).map((k) => JSON.stringify([...k])));
305
+ const shared = table.columns.filter((c) => columns.includes(c));
306
+ const missing = required.filter((c) => !table.columns.includes(c));
307
+ const entry = {
308
+ insert: [], present: 0, droppedColumns: table.columns.filter((c) => !columns.includes(c)),
309
+ defaultedColumns: columns.filter((c) => !table.columns.includes(c)), missingRequired: missing, refused: [],
310
+ };
311
+ const planned = new Set();
312
+ for (const row of table.rows) {
313
+ const values = Object.fromEntries(table.columns.map((c, i) => [c, row[i]]));
314
+ const ident = key.map((c) => values[c] ?? null);
315
+ if (key.some((c) => !table.columns.includes(c))) entry.refused.push({ key: ident, reason: 'the primary key is not in the snapshot' });
316
+ else if (planned.has(JSON.stringify(ident))) entry.refused.push({ key: ident, reason: 'the snapshot holds this key twice' });
317
+ else if (existing.has(JSON.stringify(ident))) entry.present += 1;
318
+ else if (missing.length) entry.refused.push({ key: ident, reason: `required column ${missing.join(', ')} is not in the snapshot` });
319
+ else {
320
+ planned.add(JSON.stringify(ident));
321
+ entry.insert.push(Object.fromEntries(shared.map((c) => [c, values[c]])));
322
+ }
323
+ }
324
+ plan.tables[name] = entry;
325
+ }
326
+ return plan;
327
+ }
@@ -0,0 +1,47 @@
1
+ // What every module of the Node service kit shares: the protocol name, ServiceError, ids, nowIso and expand.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_base.py. Node 20+, no dependency.
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ // Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
7
+ // The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
8
+ // Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
9
+ // placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
10
+ export const PROTOCOL = 'fabric-service/0.1';
11
+
12
+ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
13
+
14
+ // Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
15
+ // contract's service specification, Lifecycle), the same values as fabric_service.py.
16
+ // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
17
+ export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
18
+
19
+ // LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, hard exit 2 s later.
20
+ export const DRAIN_SECONDS = 8;
21
+
22
+ export const ID = /^[a-z][a-z0-9-]{1,62}$/;
23
+
24
+ export const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
25
+
26
+ // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
27
+ // DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
28
+ export const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
29
+
30
+ export const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
31
+
32
+ export const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
33
+
34
+ export class ServiceError extends Error {}
35
+
36
+ export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
37
+
38
+ /** Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be absolute,
39
+ * and a network share is never a local path (DEC-0032). */
40
+ export function expand(p, platform = process.platform) {
41
+ if (!LOCAL_PATH.test(String(p))) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
42
+ const out = /^~[\\/]/.test(p) ? path.join(os.homedir(), p.slice(2)) : p;
43
+ // On Windows an absolute path names its drive: `/x` is relative to the current drive (the Python kit refuses it too).
44
+ const absolute = platform === 'win32' ? /^[A-Za-z]:[\\/]/.test(out) : path.posix.isAbsolute(out);
45
+ if (!absolute) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
46
+ return out;
47
+ }
@@ -0,0 +1,79 @@
1
+ // A fixture conversation for the probe (check_service.py, rule board.responder): a scripted board a responder can run against without Fabric.
2
+ // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_fixture.py. Node 20+, no dependency.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Backoff, HubError, MCP_PROTOCOL_VERSION, normalProject, postMcp, toolAnswer } from './fabric-hub.mjs';
6
+ import { COMMS_PROTOCOL, RESPOND_TOOLS } from './fabric-board-shapes.mjs';
7
+ import { BoardSession, Responder } from './fabric-board-session.mjs';
8
+
9
+ const FIXTURE_PROJECT = '3f2a6b1c-0d4e-4f5a-8b6c-7d8e9f0a1b2c';
10
+
11
+ const FIXTURE_DIGEST = `sha256:${'a'.repeat(64)}`;
12
+
13
+ function fixtureAnswers(variant) {
14
+ const lease = '2026-10-13T10:02:00Z';
15
+ const status = { protocol: COMMS_PROTOCOL, board: { state: 'ready' }, project: FIXTURE_PROJECT, responders: [], transport: { telegram: 'off' }, unread: 0, idempotency: { epoch: 3 } };
16
+ if (variant === 'no-epoch') delete status.idempotency;
17
+ const request = { id: 'req_A1b2C3d4E5', state: 'claimed', effect: 'not_started', revision: 2 };
18
+ return {
19
+ 'com.status': status,
20
+ 'com.enroll': { ok: true, repeated: false, slot: { project: FIXTURE_PROJECT, capability: 'fixture.render', generation: 2, leaseExpiresAt: lease } },
21
+ 'com.claim': { ok: true, repeated: false, message: 'msg_K9j8H7g6F5', request, attempt: { id: 'att_Zx9Cv8Bn7M', generation: 2, digest: FIXTURE_DIGEST, leaseExpiresAt: lease } },
22
+ 'com.get': { id: 'msg_K9j8H7g6F5', thread: 'thr_8Hc2kQ9xA1', kind: 'request', at: '2026-10-13T10:00:00Z', digest: FIXTURE_DIGEST,
23
+ sender: { project: 'proj-requester', principal: { kind: 'agent', id: 's-1', provenance: 'trusted' } }, body: { text: 'Render the fixture.' },
24
+ request: { id: 'req_A1b2C3d4E5', target: FIXTURE_PROJECT, capability: 'fixture.render', state: 'claimed', effect: 'not_started', revision: 2 } },
25
+ 'com.accept': variant === 'fenced' ? { error: { code: 'fenced', message: 'A newer responder holds this slot; this call was not committed.', retryable: false } }
26
+ : { ok: true, repeated: false, request: { ...request, state: 'accepted', revision: 3 } },
27
+ 'com.complete': { ok: true, repeated: false, request: { ...request, state: 'completed', revision: 4 } },
28
+ };
29
+ }
30
+
31
+ /** One scripted conversation per path through this kit, with no Fabric; answers the problems found (empty: it passes). */
32
+ export async function selfCheck() {
33
+ const os = await import('node:os');
34
+ const problems = [];
35
+ for (const variant of ['whole', 'fenced', 'no-epoch']) {
36
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-board-fixture-'));
37
+ try {
38
+ const root = path.join(tmp, 'fabric');
39
+ fs.mkdirSync(root, { mode: 0o700 });
40
+ const door = path.join(root, 'hub-door-token');
41
+ fs.writeFileSync(door, 'd'.repeat(43), { mode: 0o600 });
42
+ fs.writeFileSync(path.join(root, 'hub.json'), JSON.stringify({ protocol: 'fabric-hub/0.1', origin: 'http://127.0.0.1:47070', mcp: '/mcp', doorTokenFile: door, pid: process.pid, startedAt: '2026-10-13T10:00:00Z' }));
43
+ const cred = path.join(tmp, 'credential');
44
+ fs.writeFileSync(cred, 'c'.repeat(43), { mode: 0o600 });
45
+ const answers = fixtureAnswers(variant);
46
+ const calls = [];
47
+ const handled = [];
48
+ const post = async (origin, body, bearer, { method = 'POST' } = {}) => {
49
+ if (method === 'DELETE') return { status: 200, headers: {}, body: null };
50
+ if (body.method === 'initialize') return { status: 200, headers: { 'mcp-session-id': 'fixture-session' }, body: { jsonrpc: '2.0', id: body.id, result: { protocolVersion: MCP_PROTOCOL_VERSION } } };
51
+ if (body.method !== 'tools/call') return { status: 202, headers: {}, body: null };
52
+ const tool = body.params.name;
53
+ calls.push([tool, body.params.arguments]);
54
+ if (tool === 'fabric.board.join') return { status: 200, headers: {}, body: { jsonrpc: '2.0', id: body.id, result: { structuredContent: { session: { bearer: 'b'.repeat(43), sessionId: 's-fixture', projectId: FIXTURE_PROJECT, capabilities: ['fixture.render'] } } } } };
55
+ const value = answers[tool] ?? { error: { code: 'unsupported_capability', message: 'not in the fixture', retryable: false } };
56
+ return { status: 200, headers: {}, body: { jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: JSON.stringify(value) }] } } };
57
+ };
58
+ const { HubClient } = await import('./fabric-hub.mjs');
59
+ const responder = new Responder({ hub: new HubClient(root, { post }), projectId: FIXTURE_PROJECT, capabilities: ['fixture.render'], credentialFile: cred,
60
+ checkpointFile: path.join(tmp, 'checkpoint.json'), handler: (request) => { handled.push(request.body); return { state: 'completed', summary: 'Rendered.' }; },
61
+ admit: () => null, now: () => 0, rand: () => 0.5, sessionFactory: (grant) => new BoardSession(grant, { post }) });
62
+ await responder.step();
63
+ const tools = calls.map(([t]) => t);
64
+ if (tools.some((t) => !RESPOND_TOOLS.has(t) && t !== 'fabric.board.join')) problems.push(`${variant}: a tool outside the respond scope was sent`);
65
+ if (variant === 'whole') {
66
+ if (tools.join(',') !== 'fabric.board.join,com.status,com.enroll,com.claim,com.get,com.accept,com.complete') problems.push(`whole: the calls were ${tools.join(', ')}`);
67
+ const accept = calls.find(([t]) => t === 'com.accept')?.[1]?.fence ?? {};
68
+ if (accept.digest !== FIXTURE_DIGEST || accept.generation !== 2 || accept.attempt !== 'att_Zx9Cv8Bn7M') problems.push('whole: com.accept did not carry the claim\'s exact fence');
69
+ if (handled.join('|') !== 'Render the fixture.') problems.push('whole: the handler did not see the request once');
70
+ if (Object.keys(responder.checkpoint.entries).length) problems.push('whole: the checkpoint kept a settled request');
71
+ } else if (variant === 'fenced') {
72
+ if (handled.length || tools.includes('com.complete')) problems.push('fenced: the kit kept acting after com.accept answered fenced');
73
+ } else if (tools.includes('com.enroll') || responder.state !== 'down') problems.push('no-epoch: the kit did not fail closed without an idempotency epoch');
74
+ } finally {
75
+ fs.rmSync(tmp, { recursive: true, force: true });
76
+ }
77
+ }
78
+ return problems;
79
+ }