cli-archguard 7.0.19 → 7.0.25

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.
@@ -0,0 +1,286 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const REQUEST_VERSION = 'archguard.skill.request/1.0'
4
+ const RESPONSE_VERSION = 'archguard.skill.response/1.0'
5
+ const COMPILER_VERSION = 'v7.0.25'
6
+ const CONTRACT_VERSION = 'archguard.contract/1.0'
7
+ const LEDGER_VERSION = 'archguard.checkpoint-ledger/1.0'
8
+ const SHA256 = /^[0-9a-f]{64}$/
9
+ const stringSchema = { type: 'string' }
10
+ const booleanSchema = { type: 'boolean' }
11
+ const integerSchema = { type: 'integer' }
12
+ const stringArraySchema = { type: 'array', items: stringSchema }
13
+ const strict = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required })
14
+
15
+ const TEMPLATE_DEFINITIONS = Object.freeze({
16
+ 'react-ts-vite': { frontend: { language: 'typescript', framework: 'react-18', scaffold: 'vite', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'zustand', forbiddenDeps: ['jquery', 'moment'] }, backend: null },
17
+ 'vue3-ts-vite': { frontend: { language: 'typescript', framework: 'vue-3', scaffold: 'vite', uiLibrary: 'element-plus', styling: 'css-modules', stateManagement: 'pinia', forbiddenDeps: ['jquery', 'moment'] }, backend: null },
18
+ 'nextjs-ts': { frontend: { language: 'typescript', framework: 'nextjs-15', scaffold: 'nextjs', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'react-context', forbiddenDeps: ['jquery', 'moment'] }, backend: { language: 'nodejs-20', framework: 'nextjs-route-handlers', orm: 'none', forbiddenDeps: ['request'], patterns: { required: ['schema-validation'], forbidden: ['raw-sql-concat'] } } },
19
+ 'node-nestjs': { frontend: null, backend: { language: 'nodejs-20', framework: 'nestjs-10', orm: 'prisma', forbiddenDeps: ['request'], patterns: { required: ['dto-validation', 'global-exception-filter'], forbidden: ['raw-sql-concat'] } } },
20
+ 'python-fastapi': { frontend: null, backend: { language: 'python-3.12', framework: 'fastapi', orm: 'sqlalchemy', forbiddenDeps: ['pickle5'], patterns: { required: ['schema-validation'], forbidden: ['raw-sql-concat'] } } },
21
+ 'java-spring': { frontend: null, backend: { language: 'java-21', framework: 'spring-boot-3', orm: 'jpa', forbiddenDeps: ['commons-logging'], patterns: { required: ['dto-validation', 'global-exception-handler'], forbidden: ['raw-sql-concat'] } } },
22
+ 'wechat-miniprogram': { frontend: { language: 'typescript', framework: 'wechat-miniprogram', scaffold: 'miniprogram', uiLibrary: 'native', styling: 'wxss', stateManagement: 'page-state', forbiddenDeps: ['jquery'] }, backend: null },
23
+ 'tauri-react': { frontend: { language: 'typescript', framework: 'react-18', scaffold: 'tauri-2', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'zustand', forbiddenDeps: ['electron', 'jquery'] }, backend: { language: 'rust-2024', framework: 'tauri-2', orm: 'none', forbiddenDeps: [], patterns: { required: ['command-boundary'], forbidden: ['unsafe-without-audit'] } } },
24
+ })
25
+ const DEFAULT_BUDGETS = Object.freeze({ maxNestingDepth: 4, maxFileLines: 300, maxFunctionLines: 50, animations: 3, transitions: -1, bundleBudgetKB: 500 })
26
+ const BUILTIN_RULES = Object.freeze([
27
+ { id: 'no-inline-style', category: 'standard', pattern: /\bstyle\s*=\s*(?:\{\{|["'])/g, message: 'Use the locked styling system instead of inline style.' },
28
+ { id: 'no-hardcoded-color', category: 'standard', pattern: /#[0-9a-f]{3,8}\b|\brgb(?:a)?\s*\(|\bhsl(?:a)?\s*\(/gi, message: 'Use project theme tokens instead of hardcoded colors.' },
29
+ { id: 'no-eval', category: 'security', pattern: /\beval\s*\(|new\s+Function\b/g, message: 'Dynamic code evaluation is forbidden.' },
30
+ { id: 'no-debug', category: 'standard', pattern: /\bdebugger\b|\bconsole\.(?:log|debug)\s*\(/g, message: 'Remove debug statements.' },
31
+ { id: 'no-magic-number', category: 'standard', pattern: /(?:^|[^\w.])(?:[2-9]|[1-9]\d{1,})(?:\.\d+)?(?:[^\w.]|$)/g, message: 'Name repeated or domain-significant numeric values.' },
32
+ ])
33
+ const OPERATIONS = Object.freeze(['capabilities', 'help', 'intake', 'contract-create', 'contract-get', 'contract-update', 'template-list', 'checkpoint', 'rules-scan', 'complexity-report', 'drift-status', 'ledger-query'])
34
+
35
+ const patternSchema = strict({ required: stringArraySchema, forbidden: stringArraySchema }, ['required', 'forbidden'])
36
+ const stackSideSchema = strict({ language: stringSchema, framework: stringSchema, scaffold: stringSchema, uiLibrary: stringSchema, styling: stringSchema, stateManagement: stringSchema, orm: stringSchema, forbiddenDeps: stringArraySchema, patterns: patternSchema }, ['language', 'framework', 'forbiddenDeps'])
37
+ const stackSchema = strict({ frontend: { anyOf: [stackSideSchema, { type: 'null' }] }, backend: { anyOf: [stackSideSchema, { type: 'null' }] } }, ['frontend', 'backend'])
38
+ const ruleSchema = strict({ id: stringSchema, engine: { enum: ['regex', 'ast'] }, pattern: stringSchema, message: stringSchema, blocking: booleanSchema }, ['id', 'engine', 'pattern', 'message', 'blocking'])
39
+ const rulesSchema = strict({ blocking: stringArraySchema, strictStandard: booleanSchema, custom: { type: 'array', items: ruleSchema } }, ['blocking', 'strictStandard', 'custom'])
40
+ const budgetSchema = strict(Object.fromEntries(Object.keys(DEFAULT_BUDGETS).map((key) => [key, integerSchema])), Object.keys(DEFAULT_BUDGETS))
41
+ const auditSchema = { type: 'object', additionalProperties: { type: 'string' }, minProperties: 1 }
42
+ const CONTRACT_SCHEMA = strict({ schemaVersion: { const: CONTRACT_VERSION }, contractId: stringSchema, project: stringSchema, templateId: { enum: Object.keys(TEMPLATE_DEFINITIONS) }, revision: { type: 'integer', minimum: 1 }, stack: stackSchema, rules: rulesSchema, budgets: budgetSchema, audit: auditSchema }, ['schemaVersion', 'contractId', 'project', 'templateId', 'revision', 'stack', 'rules', 'budgets', 'audit'])
43
+ const overrideSchema = strict({ stack: stackSchema, rules: rulesSchema, budgets: budgetSchema, audit: auditSchema })
44
+ const fileSchema = strict({ path: stringSchema, content: stringSchema }, ['path', 'content'])
45
+ const findingSchema = strict({ severity: { enum: ['P0', 'P1', 'P2'] }, ruleId: stringSchema, entityRef: stringSchema, message: stringSchema, category: stringSchema, blocking: booleanSchema, evidence: { type: 'object', additionalProperties: true } }, ['severity', 'ruleId', 'entityRef', 'message', 'category', 'blocking', 'evidence'])
46
+ const metricSchema = strict({ path: stringSchema, fileLines: integerSchema, maxNestingDepth: integerSchema, maxFunctionLines: integerSchema, animations: integerSchema, transitions: integerSchema }, ['path', 'fileLines', 'maxNestingDepth', 'maxFunctionLines', 'animations', 'transitions'])
47
+ const ledgerEntrySchema = strict({ schemaVersion: { const: LEDGER_VERSION }, checkpointId: stringSchema, blockId: stringSchema, path: stringSchema, contractRevision: integerSchema, beforeSha256: { type: 'string', pattern: SHA256.source }, afterSha256: { type: 'string', pattern: SHA256.source }, status: { enum: ['passed', 'blocked'] }, ruleIds: stringArraySchema, blockingRuleIds: stringArraySchema, rollbackRequired: booleanSchema }, ['schemaVersion', 'checkpointId', 'blockId', 'path', 'contractRevision', 'beforeSha256', 'afterSha256', 'status', 'ruleIds', 'blockingRuleIds', 'rollbackRequired'])
48
+ const ledgerSchema = { type: 'array', items: ledgerEntrySchema }
49
+ const digestSchema = { type: 'string', pattern: SHA256.source }
50
+ const nextSchema = strict({ operation: stringSchema, instruction: stringSchema }, ['operation'])
51
+ const driftSchema = strict({ trend: { enum: ['green', 'yellow', 'red'] }, totalBlocks: integerSchema, recentBlocks: integerSchema, blockedBlocks: integerSchema, violationRate: { type: 'number' }, consecutiveBlocks: integerSchema, repeatedRuleIds: stringArraySchema, action: stringSchema }, ['trend', 'totalBlocks', 'recentBlocks', 'blockedBlocks', 'violationRate', 'consecutiveBlocks', 'repeatedRuleIds', 'action'])
52
+ const checkpointOutputSchema = strict({ checkpoint: strict({ status: { enum: ['passed', 'blocked'] }, writeAllowed: booleanSchema, rollbackRequired: booleanSchema, findings: { type: 'array', items: findingSchema }, metrics: metricSchema, drift: driftSchema, instruction: stringSchema }, ['status', 'writeAllowed', 'rollbackRequired', 'findings', 'metrics', 'drift', 'instruction']), ledgerEntry: ledgerEntrySchema }, ['checkpoint', 'ledgerEntry'])
53
+ const scanInputSchema = strict({ contract: CONTRACT_SCHEMA, files: { type: 'array', minItems: 1, items: fileSchema } }, ['contract', 'files'])
54
+ const emptySchema = strict({})
55
+ const objectSchemaDescriptor = strict({ type: { const: 'object' }, additionalProperties: booleanSchema, properties: { type: 'object', additionalProperties: true }, required: stringArraySchema }, ['type', 'additionalProperties', 'properties', 'required'])
56
+ const schemaPair = strict({ input: objectSchemaDescriptor, output: objectSchemaDescriptor }, ['input', 'output'])
57
+ const capabilitiesOutputSchema = strict({ skill: strict({ name: stringSchema, version: stringSchema }, ['name', 'version']), operations: stringArraySchema, operationSchemas: { type: 'object', additionalProperties: schemaPair }, contractSchemaVersion: stringSchema, ledgerSchemaVersion: stringSchema, templates: stringArraySchema, executionBoundary: stringSchema, nextStep: nextSchema }, ['skill', 'operations', 'operationSchemas', 'contractSchemaVersion', 'ledgerSchemaVersion', 'templates', 'executionBoundary', 'nextStep'])
58
+ const OPERATION_SCHEMAS = Object.freeze({
59
+ capabilities: { input: emptySchema, output: capabilitiesOutputSchema },
60
+ help: { input: emptySchema, output: capabilitiesOutputSchema },
61
+ intake: { input: strict({ project: stringSchema, detectedFiles: stringArraySchema }, ['project']), output: strict({ project: stringSchema, questions: { type: 'array', items: strict({ id: stringSchema, prompt: stringSchema, required: booleanSchema, options: { type: 'array' } }, ['id', 'prompt', 'required']) }, nextStep: nextSchema }, ['project', 'questions', 'nextStep']) },
62
+ 'contract-create': { input: strict({ project: stringSchema, templateId: { enum: Object.keys(TEMPLATE_DEFINITIONS) }, overrides: overrideSchema }, ['project', 'templateId']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema, nextStep: nextSchema }, ['contract', 'digest', 'nextStep']) },
63
+ 'contract-get': { input: strict({ contract: CONTRACT_SCHEMA }, ['contract']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema }, ['contract', 'digest']) },
64
+ 'contract-update': { input: strict({ contract: CONTRACT_SCHEMA, expectedDigest: digestSchema, patch: overrideSchema, confirmation: { const: 'confirm-architecture-contract-update' }, reason: stringSchema, actor: stringSchema }, ['contract', 'expectedDigest', 'patch', 'confirmation', 'reason', 'actor']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema, audit: auditSchema }, ['contract', 'digest', 'audit']) },
65
+ 'template-list': { input: emptySchema, output: strict({ templates: { type: 'array', items: strict({ templateId: stringSchema, stack: stackSchema }, ['templateId', 'stack']) }, nextStep: nextSchema }, ['templates', 'nextStep']) },
66
+ checkpoint: { input: strict({ contract: CONTRACT_SCHEMA, block: strict({ blockId: stringSchema, path: stringSchema, content: stringSchema, beforeSha256: digestSchema }, ['blockId', 'path', 'content', 'beforeSha256']), history: ledgerSchema }, ['contract', 'block', 'history']), output: checkpointOutputSchema },
67
+ 'rules-scan': { input: scanInputSchema, output: strict({ findings: { type: 'array', items: findingSchema }, summary: strict({ total: integerSchema, blocking: integerSchema }, ['total', 'blocking']) }, ['findings', 'summary']) },
68
+ 'complexity-report': { input: scanInputSchema, output: strict({ metrics: { type: 'array', items: metricSchema }, findings: { type: 'array', items: findingSchema } }, ['metrics', 'findings']) },
69
+ 'drift-status': { input: strict({ ledger: ledgerSchema }, ['ledger']), output: strict({ drift: driftSchema }, ['drift']) },
70
+ 'ledger-query': { input: strict({ ledger: ledgerSchema, ruleId: stringSchema, status: { enum: ['passed', 'blocked'] } }, ['ledger']), output: strict({ schemaVersion: { const: LEDGER_VERSION }, entries: ledgerSchema, total: integerSchema }, ['schemaVersion', 'entries', 'total']) },
71
+ })
72
+
73
+ function canonicalJson(value) {
74
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return JSON.stringify(value)
75
+ if (typeof value === 'number') {
76
+ if (!Number.isFinite(value)) throw new Error('JSON contains a non-finite number')
77
+ return JSON.stringify(value)
78
+ }
79
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
80
+ if (!value || typeof value !== 'object') throw new Error('Value must be finite JSON')
81
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
82
+ }
83
+ const digest = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex')
84
+ const contentDigest = (value) => createHash('sha256').update(value).digest('hex')
85
+ function text(value, context) {
86
+ if (typeof value !== 'string' || value !== value.trim() || !value) throw new Error(`${context} is required`)
87
+ return value
88
+ }
89
+ function record(value, context) {
90
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${context} must be an object`)
91
+ return value
92
+ }
93
+ const response = (requestId, status, output, findings = []) => ({ ok: true, schemaVersion: RESPONSE_VERSION, requestId, status, output, findings })
94
+ const finding = (severity, ruleId, entityRef, message, category, blocking = true, evidence = {}) => ({ severity, ruleId, entityRef, message, category, blocking, evidence })
95
+ const pathSafe = (path) => typeof path === 'string' && path === path.normalize('NFC') && !path.startsWith('/') && !path.includes('\\') && path.split('/').every((part) => part && part !== '.' && part !== '..')
96
+
97
+ function normalizeCustomRule(value, index) {
98
+ const rule = record(value, `rules.custom[${index}]`)
99
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(text(rule.id, 'custom rule id')) || !['regex', 'ast'].includes(rule.engine) || typeof rule.pattern !== 'string' || rule.pattern.length > 500 || (rule.engine === 'regex' && /\\[1-9]|\(\?<|\(\?=|\(\?!/.test(rule.pattern)) || (rule.engine === 'ast' && !/^[A-Za-z][A-Za-z0-9]*$/.test(rule.pattern))) throw new Error(`rules.custom[${index}] is unsafe or unsupported`)
100
+ if (rule.engine === 'regex') new RegExp(rule.pattern, 'gu')
101
+ return { id: rule.id, engine: rule.engine, pattern: rule.pattern, message: text(rule.message, 'custom rule message'), blocking: rule.blocking === true }
102
+ }
103
+ function normalizeContract(value) {
104
+ const source = record(value, 'contract')
105
+ if (source.schemaVersion !== CONTRACT_VERSION || !TEMPLATE_DEFINITIONS[source.templateId] || !Number.isInteger(source.revision) || source.revision < 1 || !source.stack || !source.rules || !source.budgets || !source.audit) throw new Error('architecture contract is invalid')
106
+ const custom = Array.isArray(source.rules.custom) ? source.rules.custom.map(normalizeCustomRule) : []
107
+ const blocking = Array.isArray(source.rules.blocking) ? [...new Set(source.rules.blocking.map((rule) => text(rule, 'blocking rule')))].sort() : []
108
+ const budgets = Object.fromEntries(Object.keys(DEFAULT_BUDGETS).map((key) => {
109
+ const amount = source.budgets[key]
110
+ if (!Number.isInteger(amount) || amount < -1) throw new Error(`contract.budgets.${key} is invalid`)
111
+ return [key, amount]
112
+ }))
113
+ return { schemaVersion: CONTRACT_VERSION, contractId: text(source.contractId, 'contractId'), project: text(source.project, 'project'), templateId: source.templateId, revision: source.revision, stack: source.stack, rules: { blocking, strictStandard: source.rules.strictStandard === true, custom }, budgets, audit: record(source.audit, 'audit') }
114
+ }
115
+ function baseContract(project, templateId) {
116
+ const stack = TEMPLATE_DEFINITIONS[templateId]
117
+ if (!stack) throw new Error(`Unknown architecture template: ${templateId}`)
118
+ return normalizeContract({ schemaVersion: CONTRACT_VERSION, contractId: `${project}-architecture`, project, templateId, revision: 1, stack, rules: { blocking: ['no-eval', 'no-debug'], strictStandard: false, custom: [] }, budgets: DEFAULT_BUDGETS, audit: { createdBy: 'archguard', reason: 'initial-contract' } })
119
+ }
120
+ function forbiddenDependencies(contract) {
121
+ return [...new Set([...(contract.stack.frontend?.forbiddenDeps ?? []), ...(contract.stack.backend?.forbiddenDeps ?? [])])]
122
+ }
123
+ const dependencyKey = (value) => value.toLowerCase().replace(/[._]+/g, '-')
124
+ function dependencyMatches(actual, forbidden) {
125
+ const actualParts = actual.split(':')
126
+ return dependencyKey(actual) === dependencyKey(forbidden) || dependencyKey(actualParts.at(-1)) === dependencyKey(forbidden)
127
+ }
128
+ function manifestDependencies(file) {
129
+ if (/(^|\/)package\.json$/i.test(file.path)) {
130
+ const manifest = record(JSON.parse(file.content), 'package.json')
131
+ return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap((key) => Object.keys(manifest[key] ?? {}))
132
+ }
133
+ if (/(^|\/)requirements(?:-[^/]*)?\.txt$/i.test(file.path)) return file.content.split(/\r?\n/).map((line) => line.replace(/\s+#.*$/, '').trim()).filter((line) => line && !line.startsWith('-')).map((line) => line.split(/[<>=!~\[;\s]/, 1)[0])
134
+ if (/(^|\/)pom\.xml$/i.test(file.path)) return [...file.content.matchAll(/<dependency\b[\s\S]*?<artifactId>\s*([^<\s]+)\s*<\/artifactId>[\s\S]*?<\/dependency>/gi)].map((match) => match[1])
135
+ if (/(^|\/)Cargo\.toml$/.test(file.path)) {
136
+ let dependencySection = false
137
+ return file.content.split(/\r?\n/).flatMap((line) => {
138
+ const section = line.trim().match(/^\[([^\]]+)]$/)
139
+ if (section) dependencySection = /(^|\.)((dev|build)-)?dependencies$/.test(section[1])
140
+ const dependency = dependencySection ? line.match(/^\s*([A-Za-z0-9_-]+)\s*=/)?.[1] : undefined
141
+ return dependency ? [dependency] : []
142
+ })
143
+ }
144
+ return []
145
+ }
146
+ function dependencyFindings(contract, file) {
147
+ const forbidden = forbiddenDependencies(contract)
148
+ let declared
149
+ try {
150
+ declared = manifestDependencies(file)
151
+ } catch (error) {
152
+ return [finding('P0', 'ARCH-DEPENDENCY-MANIFEST-INVALID', file.path, error instanceof Error ? error.message : 'Dependency manifest is invalid.', 'architecture')]
153
+ }
154
+ const found = new Set()
155
+ for (const dependency of forbidden) {
156
+ const escaped = dependency.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
157
+ const imported = new RegExp(`(?:from\\s+|require\\(\\s*|import\\(\\s*)["']${escaped}(?:[\\/"'])`).test(file.content)
158
+ if (imported || declared.some((item) => dependencyMatches(item, dependency))) found.add(dependency)
159
+ }
160
+ return [...found].map((dependency) => finding('P0', 'ARCH-FORBIDDEN-DEPENDENCY', file.path, `Dependency ${dependency} is forbidden by the contract.`, 'architecture', true, { dependency }))
161
+ }
162
+ function languageFindings(contract, file) {
163
+ if (!pathSafe(file.path)) return [finding('P0', 'ARCH-PATH', String(file.path), 'File path is unsafe.', 'architecture')]
164
+ const findings = []
165
+ if (file.path === 'arch.contract.yaml') findings.push(finding('P0', 'ARCH-CONTRACT-MUTATION', file.path, 'The locked architecture contract cannot be changed by checkpoint.', 'architecture'))
166
+ const languages = [contract.stack.frontend?.language, contract.stack.backend?.language].filter(Boolean)
167
+ const extensions = new Set(languages.flatMap((language) => language === 'typescript' ? ['.ts', '.tsx'] : language.startsWith('nodejs') ? ['.js', '.mjs', '.cjs'] : language.startsWith('python') ? ['.py'] : language.startsWith('java') ? ['.java'] : language.startsWith('rust') ? ['.rs'] : []))
168
+ const codeExtension = file.path.match(/\.[A-Za-z0-9]+$/)?.[0]
169
+ if (codeExtension && ['.js', '.jsx', '.ts', '.tsx', '.py', '.java', '.rs'].includes(codeExtension) && extensions.size && !extensions.has(codeExtension)) findings.push(finding('P0', 'ARCH-LANGUAGE', file.path, `File extension ${codeExtension} is outside the locked languages.`, 'architecture', true, { allowedExtensions: [...extensions] }))
170
+ return findings.concat(dependencyFindings(contract, file))
171
+ }
172
+ function ruleFindings(contract, file) {
173
+ const findings = []
174
+ for (const rule of BUILTIN_RULES) {
175
+ const matches = [...file.content.matchAll(new RegExp(rule.pattern.source, rule.pattern.flags))]
176
+ if (!matches.length) continue
177
+ const blocking = rule.category === 'security' || contract.rules.strictStandard || contract.rules.blocking.includes(rule.id)
178
+ findings.push(finding(blocking ? 'P1' : 'P2', rule.id, file.path, rule.message, rule.category, blocking, { matches: matches.length }))
179
+ }
180
+ for (const rule of contract.rules.custom) {
181
+ if (rule.engine !== 'regex') continue
182
+ const matches = [...file.content.matchAll(new RegExp(rule.pattern, 'gu'))]
183
+ if (matches.length) findings.push(finding(rule.blocking ? 'P1' : 'P2', rule.id, file.path, rule.message, 'custom', rule.blocking, { matches: matches.length }))
184
+ }
185
+ return findings
186
+ }
187
+ function complexity(file) {
188
+ const lines = file.content.split(/\r?\n/)
189
+ let depth = 0; let maximumDepth = 0; let functionStart = null; let maximumFunctionLines = 0
190
+ for (const [index, line] of lines.entries()) {
191
+ if (functionStart === null && /\bfunction\b|=>\s*\{|\b(?:public|private|protected)?\s*(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/.test(line)) functionStart = { line: index, depth }
192
+ for (const character of line.replace(/(['"`]).*?\1/g, '')) {
193
+ if (character === '{') { depth += 1; maximumDepth = Math.max(maximumDepth, depth) } else if (character === '}') depth = Math.max(0, depth - 1)
194
+ }
195
+ if (functionStart && depth <= functionStart.depth) { maximumFunctionLines = Math.max(maximumFunctionLines, index - functionStart.line + 1); functionStart = null }
196
+ }
197
+ if (functionStart) maximumFunctionLines = Math.max(maximumFunctionLines, lines.length - functionStart.line)
198
+ return { path: file.path, fileLines: lines.length, maxNestingDepth: maximumDepth, maxFunctionLines: maximumFunctionLines, animations: (file.content.match(/@keyframes\b|\banimation(?:-name)?\s*:/g) ?? []).length, transitions: (file.content.match(/\btransition(?:-property)?\s*:/g) ?? []).length }
199
+ }
200
+ function complexityFindings(contract, metric) {
201
+ const checks = [['maxFileLines', 'fileLines'], ['maxNestingDepth', 'maxNestingDepth'], ['maxFunctionLines', 'maxFunctionLines'], ['animations', 'animations'], ['transitions', 'transitions']]
202
+ return checks.flatMap(([budgetKey, metricKey]) => contract.budgets[budgetKey] >= 0 && metric[metricKey] > contract.budgets[budgetKey] ? [finding('P1', `BUDGET-${budgetKey.toUpperCase()}`, metric.path, `${metricKey} ${metric[metricKey]} exceeds budget ${contract.budgets[budgetKey]}.`, 'complexity', true, { actual: metric[metricKey], budget: contract.budgets[budgetKey] })] : [])
203
+ }
204
+ function scanFiles(contract, files) {
205
+ if (!Array.isArray(files) || !files.length) throw new Error('files must be non-empty')
206
+ const normalized = files.map((value) => record(value, 'file'))
207
+ const metrics = normalized.map(complexity)
208
+ const findings = normalized.flatMap((file) => [...languageFindings(contract, file), ...ruleFindings(contract, file)]).concat(metrics.flatMap((metric) => complexityFindings(contract, metric)))
209
+ return { findings, metrics }
210
+ }
211
+ function driftState(ledger) {
212
+ if (!Array.isArray(ledger)) throw new Error('ledger history is required')
213
+ const recent = ledger.slice(-10)
214
+ const trailing = []
215
+ for (let index = recent.length - 1; index >= 0 && recent[index]?.status === 'blocked'; index -= 1) trailing.unshift(recent[index])
216
+ const shared = trailing.length ? trailing.map((entry) => new Set(entry.blockingRuleIds ?? [])).reduce((left, right) => new Set([...left].filter((ruleId) => right.has(ruleId)))) : new Set()
217
+ const repeatedRuleIds = [...shared].sort()
218
+ const consecutiveBlocks = repeatedRuleIds.length ? trailing.length : 0
219
+ const trend = consecutiveBlocks >= 3 ? 'red' : consecutiveBlocks >= 2 ? 'yellow' : 'green'
220
+ const blockedBlocks = recent.filter((entry) => entry.status === 'blocked').length
221
+ return { trend, totalBlocks: ledger.length, recentBlocks: recent.length, blockedBlocks, violationRate: recent.length ? Math.round((blockedBlocks / recent.length) * 1e3) / 10 : 0, consecutiveBlocks, repeatedRuleIds, action: trend === 'red' ? 'interrupt-and-request-human-confirmation' : trend === 'yellow' ? 'warn-and-correct-next-block' : 'continue' }
222
+ }
223
+ function checkpoint(contract, blockValue, history, trustedAstFindings) {
224
+ const block = record(blockValue, 'checkpoint block')
225
+ if (!Array.isArray(history)) throw new Error('checkpoint history is required')
226
+ if (!pathSafe(block.path) || typeof block.content !== 'string' || !SHA256.test(block.beforeSha256)) throw new Error('checkpoint block is invalid')
227
+ const scan = scanFiles(contract, [{ path: block.path, content: block.content }])
228
+ const astRules = contract.rules.custom.filter((rule) => rule.engine === 'ast')
229
+ if (astRules.length && !Array.isArray(trustedAstFindings)) scan.findings.push(finding('P1', 'ARCH-AST-LOCAL-RUNNER-REQUIRED', block.path, 'AST custom rules require the bundled trusted local checkpoint runner.', 'custom', true, { ruleIds: astRules.map((rule) => rule.id) }))
230
+ if (Array.isArray(trustedAstFindings)) for (const item of trustedAstFindings) scan.findings.push(record(item, 'trusted AST finding'))
231
+ const blocking = scan.findings.filter((item) => item.blocking)
232
+ const entry = { schemaVersion: LEDGER_VERSION, checkpointId: digest({ contract: digest(contract), block, trustedAstFindingsDigest: trustedAstFindings ? digest(trustedAstFindings) : null }), blockId: text(block.blockId, 'blockId'), path: block.path, contractRevision: contract.revision, beforeSha256: block.beforeSha256, afterSha256: contentDigest(block.content), status: blocking.length ? 'blocked' : 'passed', ruleIds: scan.findings.map((item) => item.ruleId).sort(), blockingRuleIds: blocking.map((item) => item.ruleId).sort(), rollbackRequired: blocking.length > 0 }
233
+ const drift = driftState([...history, entry])
234
+ if (drift.trend === 'red') entry.rollbackRequired = true
235
+ return { checkpoint: { status: entry.rollbackRequired ? 'blocked' : 'passed', writeAllowed: !entry.rollbackRequired, rollbackRequired: entry.rollbackRequired, findings: scan.findings, metrics: scan.metrics[0], drift, instruction: drift.trend === 'red' ? 'rollback-block-and-interrupt-aimlock' : entry.rollbackRequired ? 'rollback-block-and-correct' : 'accept-block' }, ledgerEntry: entry }
236
+ }
237
+ function capabilities() {
238
+ return { skill: { name: 'archguard', version: COMPILER_VERSION }, operations: OPERATIONS, operationSchemas: OPERATION_SCHEMAS, contractSchemaVersion: CONTRACT_VERSION, ledgerSchemaVersion: LEDGER_VERSION, templates: Object.keys(TEMPLATE_DEFINITIONS), executionBoundary: 'AST rules execute only through the bundled trusted local runner; remote JSON cannot supply AST findings.', nextStep: { operation: 'intake', instruction: 'Detect the project stack and lock an architecture contract before code generation.' } }
239
+ }
240
+ function requestEnvelope(request) {
241
+ if (!request || request.schemaVersion !== REQUEST_VERSION || typeof request.requestId !== 'string' || !OPERATIONS.includes(request.operation) || !request.input || typeof request.input !== 'object' || Array.isArray(request.input)) throw new Error('Invalid ArchGuard request envelope')
242
+ return request
243
+ }
244
+ function checkpointResponse(requestId, input, trustedAstFindings) {
245
+ if (Object.hasOwn(record(input.block, 'checkpoint block'), 'astFindings')) throw new Error('checkpoint.astFindings is not accepted from JSON callers')
246
+ const output = checkpoint(normalizeContract(input.contract), input.block, input.history, trustedAstFindings)
247
+ return response(requestId, output.checkpoint.status === 'passed' ? 'succeeded' : 'blocked', output, output.checkpoint.findings)
248
+ }
249
+ async function execute(request, trustedAstFindings) {
250
+ const { requestId, operation, input } = requestEnvelope(request)
251
+ if (operation === 'capabilities' || operation === 'help') return response(requestId, 'succeeded', capabilities())
252
+ if (operation === 'template-list') return response(requestId, 'succeeded', { templates: Object.entries(TEMPLATE_DEFINITIONS).map(([templateId, stack]) => ({ templateId, stack })), nextStep: { operation: 'contract-create' } })
253
+ if (operation === 'intake') return response(requestId, 'succeeded', { questions: [{ id: 'templateId', prompt: 'Which detected stack template must be locked?', required: true, options: Object.keys(TEMPLATE_DEFINITIONS) }, { id: 'strictStandard', prompt: 'Should standard-level findings block writes?', required: true, options: [false, true] }, { id: 'budgets', prompt: 'Confirm file, function, nesting, effect, and bundle budgets.', required: true }], project: text(input.project, 'project'), nextStep: { operation: 'contract-create' } })
254
+ if (operation === 'contract-create') {
255
+ const contract = baseContract(text(input.project, 'project'), text(input.templateId, 'templateId'))
256
+ const merged = input.overrides ? normalizeContract({ ...contract, ...input.overrides, stack: { ...contract.stack, ...(input.overrides.stack ?? {}) }, rules: { ...contract.rules, ...(input.overrides.rules ?? {}) }, budgets: { ...contract.budgets, ...(input.overrides.budgets ?? {}) } }) : contract
257
+ return response(requestId, 'succeeded', { contract: merged, digest: digest(merged), nextStep: { operation: 'checkpoint' } })
258
+ }
259
+ if (operation === 'contract-get') { const contract = normalizeContract(input.contract); return response(requestId, 'succeeded', { contract, digest: digest(contract) }) }
260
+ if (operation === 'contract-update') {
261
+ const contract = normalizeContract(input.contract)
262
+ if (input.expectedDigest !== digest(contract) || input.confirmation !== 'confirm-architecture-contract-update') throw new Error('Contract update authority is invalid')
263
+ const updated = normalizeContract({ ...contract, ...record(input.patch, 'patch'), revision: contract.revision + 1, audit: { updatedBy: text(input.actor, 'actor'), reason: text(input.reason, 'reason'), previousDigest: input.expectedDigest } })
264
+ return response(requestId, 'succeeded', { contract: updated, digest: digest(updated), audit: updated.audit })
265
+ }
266
+ if (operation === 'checkpoint') return checkpointResponse(requestId, input, trustedAstFindings)
267
+ if (operation === 'rules-scan' || operation === 'complexity-report') {
268
+ const scan = scanFiles(normalizeContract(input.contract), input.files)
269
+ const output = operation === 'rules-scan' ? { findings: scan.findings, summary: { total: scan.findings.length, blocking: scan.findings.filter((item) => item.blocking).length } } : { metrics: scan.metrics, findings: scan.findings.filter((item) => item.category === 'complexity') }
270
+ return response(requestId, scan.findings.some((item) => item.blocking) ? 'blocked' : 'succeeded', output, scan.findings)
271
+ }
272
+ if (operation === 'drift-status') return response(requestId, 'succeeded', { drift: driftState(input.ledger) })
273
+ if (!Array.isArray(input.ledger)) throw new Error('ledger is required')
274
+ const entries = input.ledger.filter((entry) => (!input.ruleId || entry.ruleIds?.includes(input.ruleId)) && (!input.status || entry.status === input.status))
275
+ return response(requestId, 'succeeded', { schemaVersion: LEDGER_VERSION, entries, total: entries.length })
276
+ }
277
+
278
+ async function run(request) {
279
+ return execute(request, undefined)
280
+ }
281
+ async function runTrustedLocalCheckpoint(request, trustedAstFindings) {
282
+ if (request?.operation !== 'checkpoint' || !Array.isArray(trustedAstFindings)) throw new Error('trusted local checkpoint authority is invalid')
283
+ return execute(request, trustedAstFindings.map((item) => record(item, 'trusted AST finding')))
284
+ }
285
+
286
+ export { CONTRACT_VERSION, LEDGER_VERSION, OPERATION_SCHEMAS, TEMPLATE_DEFINITIONS, run, runTrustedLocalCheckpoint }
package/broker.mjs ADDED
@@ -0,0 +1,301 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open } from 'node:fs/promises'
4
+ import { resolve, win32 } from 'node:path'
5
+
6
+ export const LOOKUP_TIMEOUT_MS = 8000
7
+ export const CALL_TIMEOUT_MS = 120_000
8
+ const FEEDBACK_API_PATH = '/api/v1/telemetry/skill-usage'
9
+ const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
10
+ const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
11
+ const AUTH_SCHEME = 'BrainClient'
12
+ const TOKEN_FILE_MAX_BYTES = 16_384
13
+ const POSIX_TOKEN_FILE_MODE = 0o600
14
+ const WINDOWS_BROKER_DIRECTORY = ['CLI.Tax', 'broker']
15
+ const FEEDBACK_COMMENT_MAX = 500
16
+ const EVALUATION_DURATION_MAX = 86_400_000
17
+ const SCORE_MIN = 0
18
+ const SCORE_MAX = 100
19
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
20
+ const INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
21
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/
22
+ const PROTOCOL_STATUSES = new Set(['succeeded', 'blocked', 'failed'])
23
+ const VALIDATION_STATES = new Set(['passed', 'failed', 'incomplete'])
24
+ const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
25
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
26
+ const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
27
+
28
+ function asObject(value, label) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
30
+ throw new Error(`${label} must be an object`)
31
+ }
32
+ return value
33
+ }
34
+
35
+ function requiredString(value, label) {
36
+ const text = typeof value === 'string' ? value.trim() : ''
37
+ if (!text) throw new Error(`${label} is required`)
38
+ return text
39
+ }
40
+
41
+ function boundedInteger(value, label) {
42
+ if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) {
43
+ throw new Error(`${label} must be a non-negative safe integer`)
44
+ }
45
+ return value
46
+ }
47
+
48
+ function insideWindowsDirectory(candidate, directory) {
49
+ const relative = win32.relative(directory, candidate)
50
+ return relative === '' || (!relative.startsWith('..\\') && relative !== '..' && !win32.isAbsolute(relative))
51
+ }
52
+
53
+ export function brainClientTokenPath(environment, platform = process.platform) {
54
+ const configured = requiredString(environment[TOKEN_FILE_ENV], TOKEN_FILE_ENV)
55
+ if (platform !== 'win32') return resolve(configured)
56
+ if (!win32.isAbsolute(configured)) {
57
+ throw new Error('Windows Brain Client token file path must be absolute')
58
+ }
59
+ const localAppData = requiredString(environment.LOCALAPPDATA, 'LOCALAPPDATA')
60
+ const brokerDirectory = win32.resolve(localAppData, ...WINDOWS_BROKER_DIRECTORY)
61
+ const candidate = win32.resolve(configured)
62
+ if (!insideWindowsDirectory(candidate, brokerDirectory)) {
63
+ throw new Error(`Windows Brain Client token file must be inside ${brokerDirectory}`)
64
+ }
65
+ return candidate
66
+ }
67
+
68
+ function assertTokenFileStatus(status, platform, currentUserId) {
69
+ if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
70
+ throw new Error('Brain Client token file must be a non-empty restricted file')
71
+ }
72
+ if (platform === 'win32') return
73
+ if (!Number.isInteger(currentUserId)) {
74
+ throw new Error('Brain Client token file ownership cannot be verified')
75
+ }
76
+ if (status.uid !== currentUserId || (status.mode & 0o777) !== POSIX_TOKEN_FILE_MODE) {
77
+ throw new Error('Brain Client token file must be owned by the current user with mode 0600')
78
+ }
79
+ }
80
+
81
+ function parseTokenFile(source) {
82
+ let tokenFile
83
+ try {
84
+ tokenFile = asObject(JSON.parse(source), 'Brain Client token file')
85
+ } catch {
86
+ throw new Error('Brain Client token file must contain valid JSON')
87
+ }
88
+ const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
89
+ if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
90
+ throw new Error('Brain Client token file contains unknown or missing fields')
91
+ }
92
+ return tokenFile
93
+ }
94
+
95
+ export async function brainClientAuthorization(context, environment, dependencies = {}) {
96
+ const platform = dependencies.platform ?? process.platform
97
+ const tokenFilePath = brainClientTokenPath(environment, platform)
98
+ const inspectPath = dependencies.lstat ?? lstat
99
+ const openPath = dependencies.open ?? open
100
+ const currentUserId = platform === 'win32'
101
+ ? null
102
+ : (dependencies.getuid ?? process.getuid)?.()
103
+ const linkStatus = await inspectPath(tokenFilePath)
104
+ if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
105
+ const noFollow = platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0)
106
+ const handle = await openPath(tokenFilePath, constants.O_RDONLY | noFollow)
107
+ try {
108
+ const status = await handle.stat()
109
+ assertTokenFileStatus(status, platform, currentUserId)
110
+ const tokenFile = parseTokenFile(await handle.readFile('utf8'))
111
+ const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
112
+ if (tokenFile.schemaVersion !== TOKEN_FILE_VERSION
113
+ || tokenFile.authorizationScheme !== AUTH_SCHEME
114
+ || endpoint.origin !== new URL(context.endpoint).origin
115
+ || endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
116
+ || endpoint.username || endpoint.password
117
+ || !TOKEN_PATTERN.test(tokenFile.token)) {
118
+ throw new Error('Brain Client token file authority is invalid')
119
+ }
120
+ return `${AUTH_SCHEME} ${tokenFile.token}`
121
+ } finally {
122
+ await handle.close()
123
+ }
124
+ }
125
+
126
+ function canonicalJson(value) {
127
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
128
+ return JSON.stringify(value)
129
+ }
130
+ if (typeof value === 'number') {
131
+ if (!Number.isFinite(value)) throw new Error('automatic evaluation contains a non-finite number')
132
+ return JSON.stringify(value)
133
+ }
134
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
135
+ const record = asObject(value, 'automatic evaluation')
136
+ return `{${Object.keys(record).sort().map((key) => (
137
+ `${JSON.stringify(key)}:${canonicalJson(record[key])}`
138
+ )).join(',')}}`
139
+ }
140
+
141
+ function expectedResponseSchema(requestSchema) {
142
+ const matched = requiredString(requestSchema, 'skill request schemaVersion')
143
+ .match(REQUEST_SCHEMA_PATTERN)
144
+ if (!matched) throw new Error('skill request schemaVersion is invalid')
145
+ return `${matched[1]}.response/${matched[2]}`
146
+ }
147
+
148
+ function protocolResponse(protocolValue, requestEnvelope) {
149
+ const protocol = asObject(protocolValue, 'skill protocol response')
150
+ const responseSchema = expectedResponseSchema(requestEnvelope.schemaVersion)
151
+ if (protocol.schemaVersion !== responseSchema) {
152
+ throw new Error('skill protocol response schemaVersion does not match the request')
153
+ }
154
+ if (protocol.requestId !== requestEnvelope.requestId) {
155
+ throw new Error('skill protocol response requestId does not match the request')
156
+ }
157
+ const status = requiredString(protocol.status, 'skill protocol status')
158
+ if (!PROTOCOL_STATUSES.has(status)) {
159
+ throw new Error('skill protocol status must be succeeded, blocked, or failed')
160
+ }
161
+ return { protocol, responseSchema, status }
162
+ }
163
+
164
+ export function authoritativeEvaluation(value, expected) {
165
+ const evaluation = asObject(value, 'server automatic evaluation')
166
+ const expectedKeys = [
167
+ 'digest', 'durationMs', 'findingCount', 'operation', 'p0Count', 'p1Count', 'p2Count',
168
+ 'requestId', 'responseSchemaVersion', 'schemaVersion', 'score', 'status', 'userComment',
169
+ 'validation',
170
+ ]
171
+ if (Object.keys(evaluation).sort().join('\n') !== expectedKeys.join('\n')) {
172
+ throw new Error('server automatic evaluation contains unknown or missing fields')
173
+ }
174
+ if (evaluation.schemaVersion !== EVALUATION_SCHEMA
175
+ || evaluation.operation !== expected.operation
176
+ || evaluation.requestId !== expected.requestId
177
+ || evaluation.responseSchemaVersion !== expected.responseSchema
178
+ || evaluation.status !== expected.status
179
+ || !VALIDATION_STATES.has(evaluation.validation)
180
+ || typeof evaluation.userComment !== 'string' || !evaluation.userComment.trim()
181
+ || Buffer.byteLength(evaluation.userComment, 'utf8') > FEEDBACK_COMMENT_MAX
182
+ || typeof evaluation.digest !== 'string' || !DIGEST_PATTERN.test(evaluation.digest)) {
183
+ throw new Error('server automatic evaluation authority is invalid')
184
+ }
185
+ for (const field of ['durationMs', 'findingCount', 'p0Count', 'p1Count', 'p2Count', 'score']) {
186
+ boundedInteger(evaluation[field], `server automatic evaluation ${field}`)
187
+ }
188
+ if (evaluation.score < SCORE_MIN || evaluation.score > SCORE_MAX
189
+ || evaluation.durationMs > EVALUATION_DURATION_MAX
190
+ || evaluation.findingCount < evaluation.p0Count + evaluation.p1Count + evaluation.p2Count) {
191
+ throw new Error('server automatic evaluation bounds are invalid')
192
+ }
193
+ if ((evaluation.status !== 'succeeded' || evaluation.validation !== 'passed'
194
+ || evaluation.p0Count > 0 || evaluation.p1Count > 0) && evaluation.score >= 60) {
195
+ throw new Error('server automatic evaluation cannot report a positive score')
196
+ }
197
+ const { digest, ...core } = evaluation
198
+ const actualDigest = createHash('sha256').update(canonicalJson(core)).digest('hex')
199
+ if (digest !== actualDigest) throw new Error('server automatic evaluation digest is invalid')
200
+ return evaluation
201
+ }
202
+
203
+ async function responsePayload(response, label) {
204
+ try {
205
+ return asObject(await response.json(), label)
206
+ } catch {
207
+ throw new Error(`${label} is not valid JSON (HTTP ${response.status})`)
208
+ }
209
+ }
210
+
211
+ function invocationRequest(context, operation, input) {
212
+ const normalizedOperation = requiredString(operation, 'skill operation')
213
+ if (!IDENTIFIER_PATTERN.test(normalizedOperation)) {
214
+ throw new Error('skill operation is invalid')
215
+ }
216
+ expectedResponseSchema(context.schemaVersion)
217
+ return {
218
+ schemaVersion: context.schemaVersion,
219
+ requestId: `${context.runtimeCode}-${randomUUID()}`,
220
+ operation: normalizedOperation,
221
+ input: asObject(input, 'skill operation input'),
222
+ }
223
+ }
224
+
225
+ export async function invokeOfficialSkill(context, operation, input, dependencies) {
226
+ const environment = asObject(dependencies.environment, 'broker environment')
227
+ if (typeof dependencies.request !== 'function') {
228
+ throw new Error('broker request dependency is required')
229
+ }
230
+ const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
231
+ const requestEnvelope = invocationRequest(context, operation, input)
232
+ let response
233
+ try {
234
+ response = await dependencies.request(context.endpoint, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json', Authorization: authorization },
237
+ body: JSON.stringify({ input: requestEnvelope }),
238
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
239
+ })
240
+ } catch {
241
+ throw new Error(`${context.displayName} ${operation} invocation failed`)
242
+ }
243
+ const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
244
+ if (!response.ok || payload.ok !== true) {
245
+ throw new Error(`${context.displayName} ${operation} failed: HTTP ${response.status}`)
246
+ }
247
+ const invocationId = payload.feedbackInvocationId
248
+ if (typeof invocationId !== 'string' || !INVOCATION_PATTERN.test(invocationId)) {
249
+ throw new Error(`${context.displayName} ${operation} response is missing a valid feedbackInvocationId`)
250
+ }
251
+ const feedbackReceiptId = payload.feedbackReceiptId
252
+ const feedbackRequestId = payload.feedbackRequestId
253
+ if (typeof feedbackReceiptId !== 'string' || !INVOCATION_PATTERN.test(feedbackReceiptId)
254
+ || feedbackRequestId !== `automatic-${invocationId}`) {
255
+ throw new Error(`${context.displayName} ${operation} response is missing a committed feedback receipt`)
256
+ }
257
+ const protocolAuthority = protocolResponse(payload.output, requestEnvelope)
258
+ const evaluation = authoritativeEvaluation(payload.feedbackEvaluation, {
259
+ operation: requestEnvelope.operation,
260
+ requestId: requestEnvelope.requestId,
261
+ responseSchema: protocolAuthority.responseSchema,
262
+ status: protocolAuthority.status,
263
+ })
264
+ const feedback = {
265
+ id: feedbackReceiptId,
266
+ requestId: feedbackRequestId,
267
+ duplicated: false,
268
+ }
269
+ return { response: payload, invocationId, evaluation, feedback }
270
+ }
271
+
272
+ export async function callOfficialSkill(context, operation, input, dependencies) {
273
+ return (await invokeOfficialSkill(context, operation, input, dependencies)).response
274
+ }
275
+
276
+ export function invokeCommandInput(args) {
277
+ const operation = requiredString(args[1], 'skill operation')
278
+ const source = args.slice(2).join(' ').trim()
279
+ if (!source) return { operation, input: {} }
280
+ try {
281
+ return { operation, input: asObject(JSON.parse(source), 'skill operation input') }
282
+ } catch {
283
+ throw new Error('skill operation input must be a JSON object')
284
+ }
285
+ }
286
+
287
+ export function brokerCommandInput(source) {
288
+ let parsed
289
+ try {
290
+ parsed = asObject(JSON.parse(source), 'broker request')
291
+ } catch {
292
+ throw new Error('broker request must be a JSON object')
293
+ }
294
+ if (Object.keys(parsed).some((key) => !['operation', 'input'].includes(key))) {
295
+ throw new Error('broker request contains unknown fields')
296
+ }
297
+ return {
298
+ operation: requiredString(parsed.operation, 'skill operation'),
299
+ input: asObject(parsed.input, 'skill operation input'),
300
+ }
301
+ }