@cr1992/agentkit 1.0.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 (104) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +107 -0
  4. package/README.md +103 -0
  5. package/bin/agentkit.mjs +4 -0
  6. package/bin/cli.mjs +273 -0
  7. package/core/atomic-fs.mjs +23 -0
  8. package/core/cli-help.mjs +54 -0
  9. package/core/content-digest.mjs +66 -0
  10. package/core/digest.mjs +67 -0
  11. package/core/json-schema-lite.mjs +60 -0
  12. package/core/legacy-entry.mjs +37 -0
  13. package/core/reflection.mjs +142 -0
  14. package/core/runtime-bundle.mjs +101 -0
  15. package/docs/loop/embedded-review-adapter.md +41 -0
  16. package/docs/loop/loop-state-machine.md +43 -0
  17. package/docs/loop/recovery-and-fuses.md +34 -0
  18. package/docs/orchestrate/dispatch-contract.md +92 -0
  19. package/docs/orchestrate/failure-routing-and-recovery.md +46 -0
  20. package/docs/orchestrate/host-capability-cache.md +170 -0
  21. package/docs/orchestrate/isolation-fallback.md +18 -0
  22. package/docs/orchestrate/model-routing-config.md +186 -0
  23. package/docs/orchestrate/orchestration-runtime.md +261 -0
  24. package/docs/orchestrate/review-budget.md +90 -0
  25. package/docs/orchestrate/task-playbooks.md +85 -0
  26. package/docs/orchestrate/user-facing-reporting.md +14 -0
  27. package/docs/verify/evidence-schema.md +167 -0
  28. package/docs/verify/input-preparation.md +44 -0
  29. package/docs/verify/verification-protocol.md +76 -0
  30. package/docs/worktree/batch-integration.md +176 -0
  31. package/docs/worktree/delivery-identity.md +41 -0
  32. package/docs/worktree/profile.md +107 -0
  33. package/docs/worktree/reclaim-and-watch.md +96 -0
  34. package/docs/worktree/review-lifecycle.md +92 -0
  35. package/docs/worktree/spawn-and-stack.md +74 -0
  36. package/domains/loop/loop-runtime.mjs +1056 -0
  37. package/domains/orchestrate/contract-tool.mjs +169 -0
  38. package/domains/orchestrate/host_capability_cache.mjs +437 -0
  39. package/domains/orchestrate/orchestration-ledger.mjs +332 -0
  40. package/domains/orchestrate/orchestration-metadata.mjs +4 -0
  41. package/domains/orchestrate/orchestration-reflection.mjs +119 -0
  42. package/domains/orchestrate/resolve_model_policy.mjs +311 -0
  43. package/domains/orchestrate/review-budget.mjs +162 -0
  44. package/domains/orchestrate/worker-capability-preflight.mjs +227 -0
  45. package/domains/verify/verification-runtime.mjs +1638 -0
  46. package/domains/worktree/worktree-archive.mjs +135 -0
  47. package/domains/worktree/worktree-artifact.mjs +123 -0
  48. package/domains/worktree/worktree-batch-integrate.mjs +713 -0
  49. package/domains/worktree/worktree-batch-plan.mjs +198 -0
  50. package/domains/worktree/worktree-batch-result.mjs +241 -0
  51. package/domains/worktree/worktree-core.mjs +908 -0
  52. package/domains/worktree/worktree-doctor.mjs +493 -0
  53. package/domains/worktree/worktree-history.mjs +377 -0
  54. package/domains/worktree/worktree-learning.mjs +110 -0
  55. package/domains/worktree/worktree-lifecycle.mjs +786 -0
  56. package/domains/worktree/worktree-merge-preview.mjs +409 -0
  57. package/domains/worktree/worktree-mgr.mjs +261 -0
  58. package/domains/worktree/worktree-process.mjs +55 -0
  59. package/domains/worktree/worktree-profile.mjs +800 -0
  60. package/domains/worktree/worktree-provider-gitlab.mjs +59 -0
  61. package/domains/worktree/worktree-reclaim.mjs +683 -0
  62. package/domains/worktree/worktree-review-refresh.mjs +574 -0
  63. package/domains/worktree/worktree-review-watch.mjs +661 -0
  64. package/domains/worktree/worktree-scan.mjs +510 -0
  65. package/domains/worktree/worktree-trace-test-worker.mjs +23 -0
  66. package/domains/worktree/worktree-trace.mjs +478 -0
  67. package/manage-worktrees/SKILL.md +87 -0
  68. package/manage-worktrees/agents/openai.yaml +4 -0
  69. package/manage-worktrees/scripts/worktree-mgr.mjs +10 -0
  70. package/manage-worktrees/scripts/worktree-scan.mjs +10 -0
  71. package/orchestrate-subagents/SKILL.md +173 -0
  72. package/orchestrate-subagents/agents/openai.yaml +4 -0
  73. package/orchestrate-subagents/scripts/contract-tool.mjs +10 -0
  74. package/orchestrate-subagents/scripts/host_capability_cache.mjs +10 -0
  75. package/orchestrate-subagents/scripts/orchestration-ledger.mjs +10 -0
  76. package/orchestrate-subagents/scripts/orchestration-reflection.mjs +10 -0
  77. package/orchestrate-subagents/scripts/resolve_model_policy.mjs +10 -0
  78. package/orchestrate-subagents/scripts/review-budget.mjs +10 -0
  79. package/orchestrate-subagents/scripts/worker-capability-preflight.mjs +10 -0
  80. package/package.json +48 -0
  81. package/run-agent-verify-loop/SKILL.md +127 -0
  82. package/run-agent-verify-loop/agents/openai.yaml +4 -0
  83. package/run-agent-verify-loop/scripts/loop-runtime.mjs +10 -0
  84. package/schemas/artifact-ref-v1.schema.json +23 -0
  85. package/schemas/batch-result-v1.schema.json +138 -0
  86. package/schemas/controller-recheck-record-v1.schema.json +22 -0
  87. package/schemas/convergence-report-v1.schema.json +9 -0
  88. package/schemas/effective-worker-capability-v1.schema.json +36 -0
  89. package/schemas/embedded-verification-record-v1.schema.json +32 -0
  90. package/schemas/evidence-package-v1.schema.json +41 -0
  91. package/schemas/improvement-proposal-v1.schema.json +18 -0
  92. package/schemas/loop-state-v1.schema.json +34 -0
  93. package/schemas/model-policy-resolution-v1.schema.json +41 -0
  94. package/schemas/orchestration-ledger-v1.schema.json +110 -0
  95. package/schemas/reflection-record-v1.schema.json +24 -0
  96. package/schemas/review-result-v1.schema.json +37 -0
  97. package/schemas/task-contract-v1.schema.json +83 -0
  98. package/schemas/verification-profile-v1.schema.json +60 -0
  99. package/schemas/worker-capability-requirements-v1.schema.json +21 -0
  100. package/schemas/worktree-binding-v1.schema.json +14 -0
  101. package/shell-manifest.json +79 -0
  102. package/verify-agent-output/SKILL.md +119 -0
  103. package/verify-agent-output/agents/openai.yaml +4 -0
  104. package/verify-agent-output/scripts/verification-runtime.mjs +10 -0
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { readFileSync } from 'node:fs';
6
+ import { realpathSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { isHelpRequest, renderCliHelp } from '../../core/cli-help.mjs';
10
+ import { createDigestKit } from '../../core/digest.mjs';
11
+
12
+ export class ContractError extends Error {}
13
+
14
+ // strict=false:本 Skill 此前不校验代理对、也不拒绝非有限 number,保持原样。
15
+ // envelopeDigest 的默认摘要字段仍是 contract_digest。
16
+ export const { canonicalJson, envelopeDigest, sha256 } = createDigestKit({
17
+ ValidationError: ContractError, strict: false, defaultDigestField: 'contract_digest',
18
+ });
19
+
20
+ class Parser {
21
+ constructor(text) { this.text = text; this.index = 0; }
22
+ ws() { while (/\s/u.test(this.text[this.index] ?? '')) this.index += 1; }
23
+ parse() { const value = this.value(); this.ws(); if (this.index !== this.text.length) throw new ContractError(`JSON 尾部非法 at ${this.index}`); return value; }
24
+ value() {
25
+ this.ws(); const char = this.text[this.index];
26
+ if (char === '{') return this.object(); if (char === '[') return this.array(); if (char === '"') return this.string();
27
+ if (char === '-' || /[0-9]/u.test(char ?? '')) return this.number();
28
+ for (const [token, value] of [['true', true], ['false', false], ['null', null]]) if (this.text.startsWith(token, this.index)) { this.index += token.length; return value; }
29
+ throw new ContractError(`JSON 值非法 at ${this.index}`);
30
+ }
31
+ object() {
32
+ this.index += 1; this.ws(); const value = Object.create(null); const keys = new Set();
33
+ if (this.text[this.index] === '}') { this.index += 1; return value; }
34
+ for (;;) {
35
+ this.ws(); if (this.text[this.index] !== '"') throw new ContractError(`JSON key 非字符串 at ${this.index}`);
36
+ const key = this.string(); if (keys.has(key)) throw new ContractError(`JSON duplicate key: ${key}`); keys.add(key);
37
+ this.ws(); if (this.text[this.index] !== ':') throw new ContractError(`JSON 缺冒号 at ${this.index}`); this.index += 1; value[key] = this.value(); this.ws();
38
+ if (this.text[this.index] === '}') { this.index += 1; return value; } if (this.text[this.index] !== ',') throw new ContractError(`JSON 缺逗号 at ${this.index}`); this.index += 1;
39
+ }
40
+ }
41
+ array() {
42
+ this.index += 1; this.ws(); const value = []; if (this.text[this.index] === ']') { this.index += 1; return value; }
43
+ for (;;) { value.push(this.value()); this.ws(); if (this.text[this.index] === ']') { this.index += 1; return value; } if (this.text[this.index] !== ',') throw new ContractError(`JSON array 缺逗号 at ${this.index}`); this.index += 1; }
44
+ }
45
+ string() { const start = this.index++; let escaped = false; while (this.index < this.text.length) { const char = this.text[this.index]; if (!escaped && char === '"') { this.index += 1; const value = JSON.parse(this.text.slice(start, this.index)); if (/\p{Surrogate}/u.test(value)) throw new ContractError('JSON string 含未配对 surrogate'); return value; } escaped = !escaped && char === '\\'; this.index += 1; } throw new ContractError('JSON string 未闭合'); }
46
+ number() { const match = this.text.slice(this.index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u); if (!match) throw new ContractError('JSON number 非法'); this.index += match[0].length; const value = Number(match[0]); if (!Number.isFinite(value)) throw new ContractError('JSON number 必须有限'); return value; }
47
+ }
48
+
49
+ export function parseJsonStrict(text) { return new Parser(text).parse(); }
50
+ export function validateContract(contract, { requireDigest = true } = {}) {
51
+ const required = ['schema_version', 'contract_id', 'objective', 'scope', 'acceptance', 'permissions', 'environment', 'skill_set', 'stop_conditions', 'extensions'];
52
+ if (contract?.schema_version !== 1) throw new ContractError('Task Contract schema_version 必须为 1');
53
+ for (const field of required) if (!Object.hasOwn(contract, field)) throw new ContractError(`Task Contract 缺少 ${field}`);
54
+ if (!contract.contract_id || !contract.objective || !Array.isArray(contract.scope?.include) || !Array.isArray(contract.scope?.exclude) || [...contract.scope.include, ...contract.scope.exclude].some((item) => typeof item !== 'string' || !item)) throw new ContractError('Task Contract identity/scope 无效');
55
+ if (!Array.isArray(contract.acceptance) || !contract.acceptance.length) throw new ContractError('acceptance 不能为空');
56
+ const ids = new Set();
57
+ for (const item of contract.acceptance) { if (!item?.contract_item_id || !item?.requirement || ids.has(item.contract_item_id)) throw new ContractError('acceptance ID 缺失或重复'); ids.add(item.contract_item_id); }
58
+ if (!['read_only', 'write'].includes(contract.permissions?.mode) || !Array.isArray(contract.permissions?.writable_paths) || contract.permissions.writable_paths.some((item) => typeof item !== 'string' || !item)) throw new ContractError('permissions 无效');
59
+ if (contract.permissions.mode === 'read_only' && contract.permissions.writable_paths.length) throw new ContractError('read_only 合同不能声明 writable_paths');
60
+ if (typeof contract.environment?.repository !== 'string' || !contract.environment.repository || !['shared_tree', 'worktree', 'caller_supplied'].includes(contract.environment?.isolation)) throw new ContractError('environment 无效');
61
+ if (!Array.isArray(contract.stop_conditions) || !contract.extensions || typeof contract.extensions !== 'object' || Array.isArray(contract.extensions)) throw new ContractError('stop_conditions/extensions 无效');
62
+ const verification = contract.extensions.verification;
63
+ if (verification !== undefined && (
64
+ !verification || typeof verification !== 'object' || Array.isArray(verification)
65
+ || !['none', 'verify-agent-output', 'run-agent-verify-loop'].includes(verification.provider)
66
+ )) throw new ContractError('extensions.verification 无效');
67
+ const reviewPolicy = contract.extensions.review_policy;
68
+ if (reviewPolicy !== undefined) {
69
+ const expected = ['max_escalation_reviews_per_artifact', 'max_primary_reviews_per_artifact', 'max_review_input_tokens', 'require_distinct_lens', 'review_only_after_smoke_pass', 'schema_version'];
70
+ const keys = reviewPolicy && typeof reviewPolicy === 'object' && !Array.isArray(reviewPolicy) ? Object.keys(reviewPolicy).sort() : [];
71
+ if (
72
+ reviewPolicy?.schema_version !== 1
73
+ || JSON.stringify(keys) !== JSON.stringify(expected)
74
+ || !Number.isSafeInteger(reviewPolicy.max_primary_reviews_per_artifact) || reviewPolicy.max_primary_reviews_per_artifact < 0
75
+ || !Number.isSafeInteger(reviewPolicy.max_escalation_reviews_per_artifact) || reviewPolicy.max_escalation_reviews_per_artifact < 0
76
+ || !Number.isSafeInteger(reviewPolicy.max_review_input_tokens) || reviewPolicy.max_review_input_tokens < 1
77
+ || typeof reviewPolicy.require_distinct_lens !== 'boolean'
78
+ || typeof reviewPolicy.review_only_after_smoke_pass !== 'boolean'
79
+ ) throw new ContractError('extensions.review_policy 无效');
80
+ }
81
+ if (!Array.isArray(contract.skill_set)) throw new ContractError('skill_set 必须是数组');
82
+ const skills = new Set();
83
+ for (const skill of contract.skill_set) {
84
+ if (!skill?.name || skills.has(skill.name) || !/^sha256:[0-9a-f]{64}$/u.test(String(skill.content_digest ?? '')) || !['primary', 'optional'].includes(skill.provider_mode)) throw new ContractError('skill_set entry 无效或重复');
85
+ skills.add(skill.name);
86
+ }
87
+ const common = new Set(required.concat('contract_digest'));
88
+ for (const key of Object.keys(contract.extensions)) if (common.has(key)) throw new ContractError(`extension 覆盖公共字段: ${key}`);
89
+ if (requireDigest && (!/^sha256:[0-9a-f]{64}$/u.test(String(contract.contract_digest ?? '')) || envelopeDigest(contract) !== contract.contract_digest)) throw new ContractError('contract_digest 无效');
90
+ return contract;
91
+ }
92
+
93
+ export function normalizeContract(contract) {
94
+ const normalized = structuredClone(contract);
95
+ delete normalized.contract_digest;
96
+ validateContract(normalized, { requireDigest: false });
97
+ normalized.contract_digest = envelopeDigest(normalized);
98
+ return normalized;
99
+ }
100
+
101
+ export function acceptanceItemDigest(item) { return sha256(Buffer.from(canonicalJson(item), 'utf8')); }
102
+
103
+ // 合同投影:从公共父合同切出节点级、产物专属的验证合同。acceptance 条目逐字节 verbatim 拷贝,
104
+ // 其余公共字段原样继承,血缘写入 extensions.projection 供 ledger 校验。
105
+ export function projectContract(parent, itemIds, { contractId = null } = {}) {
106
+ validateContract(parent);
107
+ if (!Array.isArray(itemIds) || !itemIds.length) throw new ContractError('projection items 不能为空');
108
+ if (itemIds.some((id) => typeof id !== 'string' || !id)) throw new ContractError('projection item id 无效');
109
+ if (new Set(itemIds).size !== itemIds.length) throw new ContractError('projection items 重复');
110
+ const byId = new Map(parent.acceptance.map((item) => [item.contract_item_id, item]));
111
+ const acceptance = itemIds.map((id) => { const item = byId.get(id); if (!item) throw new ContractError(`projection item 不在 parent acceptance 中: ${id}`); return structuredClone(item); });
112
+ if (contractId !== null && (typeof contractId !== 'string' || !contractId)) throw new ContractError('contract-id 无效');
113
+ // 以 parent 展开为基底:只有 contract_id / acceptance / extensions / contract_digest 允许改动,
114
+ // 其余顶层字段(含 parent 自带的扩展字段)原样继承,ledger 侧会逐字段核对全等。
115
+ const projected = {
116
+ ...structuredClone(parent),
117
+ contract_id: contractId ?? `${parent.contract_id}--proj-${randomBytes(4).toString('hex')}`,
118
+ acceptance,
119
+ extensions: { ...structuredClone(parent.extensions), projection: { parent_contract_digest: parent.contract_digest, projected_item_ids: [...itemIds] } },
120
+ };
121
+ projected.contract_digest = envelopeDigest(projected);
122
+ return validateContract(projected);
123
+ }
124
+
125
+ export function contractDiff(left, right) {
126
+ const changed = [];
127
+ for (const key of [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()) if (canonicalJson(left[key]) !== canonicalJson(right[key])) changed.push(key);
128
+ const resign = changed.some((key) => key !== 'contract_digest');
129
+ return { changed_fields: changed, requires_resign: resign, old_digest: left.contract_digest ?? null, new_digest: right.contract_digest ?? null };
130
+ }
131
+
132
+ // CLI 命令与参数的唯一真源:`--help` 清单和未知命令错误信息都从这里推导。
133
+ const CLI_SPEC = {
134
+ normalize: { required: ['input'] },
135
+ validate: { required: ['input'] },
136
+ digest: { required: ['input'] },
137
+ 'review-view': { required: ['input'] },
138
+ diff: { required: ['left', 'right'] },
139
+ project: { required: ['input', 'items'], optional: ['contract-id'] },
140
+ capabilities: {},
141
+ };
142
+ const CLI_NOTES = ['--items 是逗号分隔的 acceptance contract_item_id 列表,投影合同只能收窄这些条目。'];
143
+ function parseCli(argv) { const command = argv[0]; const options = {}; for (let i = 1; i < argv.length; i += 2) { if (!argv[i]?.startsWith('--') || argv[i + 1] === undefined) throw new ContractError('参数必须是 --name value'); options[argv[i].slice(2)] = argv[i + 1]; } return { command, options }; }
144
+ function read(path) { return parseJsonStrict(readFileSync(resolve(path), 'utf8')); }
145
+ export function main(argv = process.argv.slice(2)) {
146
+ if (isHelpRequest(argv)) return { help: renderCliHelp('contract-tool.mjs', CLI_SPEC, CLI_NOTES) };
147
+ const { command, options } = parseCli(argv);
148
+ if (command === 'normalize') return normalizeContract(read(options.input));
149
+ if (command === 'validate') { const value = validateContract(read(options.input)); return { valid: true, contract_id: value.contract_id, contract_digest: value.contract_digest }; }
150
+ if (command === 'digest') return { contract_digest: envelopeDigest(read(options.input)) };
151
+ if (command === 'review-view') { const value = validateContract(read(options.input)); return { schema_version: 1, contract_id: value.contract_id, objective: value.objective, scope: value.scope, acceptance: value.acceptance, contract_permissions: value.permissions, reviewer_permissions: { mode: 'read_only', writable_paths: [] }, environment: value.environment, contract_digest: value.contract_digest }; }
152
+ if (command === 'diff') return contractDiff(read(options.left), read(options.right));
153
+ if (command === 'project') return projectContract(read(options.input), String(options.items ?? '').split(',').map((item) => item.trim()).filter(Boolean), { contractId: options['contract-id'] ?? null });
154
+ if (command === 'capabilities') return { tool: 'contract-tool', runtime_version: '1.1.0', task_contract_versions: [1], features: ['strict-json', 'canonical-digest', 'review-view', 'resign-diff', 'contract-projection'] };
155
+ throw new ContractError(`命令必须是 ${Object.keys(CLI_SPEC).join('/')}`);
156
+ }
157
+
158
+ function isEntry() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return pathToFileURL(resolve(process.argv[1] ?? '')).href === import.meta.url; } }
159
+ export function runCli(argv = process.argv.slice(2)) {
160
+ try {
161
+ const result = main(argv);
162
+ process.stdout.write(typeof result?.help === 'string' ? result.help : `${JSON.stringify(result, null, 2)}\n`);
163
+ return 0;
164
+ } catch (error) {
165
+ process.stderr.write(`${JSON.stringify({ error: 'invalid_contract', message: error.message })}\n`);
166
+ return 2;
167
+ }
168
+ }
169
+ if (isEntry()) process.exitCode = runCli();
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join, resolve as resolvePath } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { parseJsonStrict } from './contract-tool.mjs';
9
+ import { projectConfigDir, resolveUserPath, userConfigDir } from './resolve_model_policy.mjs';
10
+
11
+ const SCHEMA_VERSION = 1;
12
+ const MAX_JSON_BYTES = 256 * 1024;
13
+ const HOST_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
14
+ const KEY_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/u;
15
+ const OBSERVED_KEYS = new Set(['schema_version', 'host', 'host_version', 'tools', 'capabilities', 'limits', 'unknown']);
16
+ const TOOL_KEYS = new Set(['name', 'parameters', 'returns']);
17
+ const SNAPSHOT_KEYS = new Set(['schema_version', 'host', 'host_version', 'generated_at', 'expires_at', 'capability_fingerprint', 'source', 'observed']);
18
+ const EVENT_KEYS = new Set(['schema_version', 'category', 'summary', 'confidence', 'evidence', 'portable']);
19
+ const CONFIDENCE_VALUES = new Set(['observed-once', 'reproduced', 'schema-confirmed']);
20
+ const MODEL_DISCOVERY_VALUES = new Set(['available', 'unavailable']);
21
+ const ISO_TZ_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/u;
22
+
23
+ export class CapabilityCacheError extends Error {}
24
+
25
+ function checkKeys(value, allowed, label) {
26
+ const unknown = Object.keys(value).filter((k) => !allowed.has(k)).sort();
27
+ if (unknown.length) {
28
+ throw new CapabilityCacheError(`${label} has unknown keys: ${unknown.join(', ')}`);
29
+ }
30
+ }
31
+
32
+ function assertHost(value) {
33
+ if (typeof value !== 'string' || !HOST_PATTERN.test(value)) {
34
+ throw new CapabilityCacheError(`host must match ${HOST_PATTERN}`);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function stringList(value, label) {
40
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item)) {
41
+ throw new CapabilityCacheError(`${label} must be a string array`);
42
+ }
43
+ return Array.from(new Set(value)).sort();
44
+ }
45
+
46
+ function flatMap(value, label) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
48
+ throw new CapabilityCacheError(`${label} must be a JSON object`);
49
+ }
50
+ const normalized = {};
51
+ for (const [key, item] of Object.entries(value)) {
52
+ if (!KEY_PATTERN.test(key)) {
53
+ throw new CapabilityCacheError(`${label} key ${JSON.stringify(key)} is invalid`);
54
+ }
55
+ if (Array.isArray(item)) {
56
+ normalized[key] = stringList(item, `${label}.${key}`);
57
+ } else if (typeof item === 'number') {
58
+ if (!Number.isFinite(item)) throw new CapabilityCacheError(`${label}.${key} must be a finite number`);
59
+ normalized[key] = item;
60
+ } else if (typeof item === 'string' || typeof item === 'boolean' || item === null) {
61
+ normalized[key] = item;
62
+ } else {
63
+ throw new CapabilityCacheError(`${label}.${key} must be a scalar or string array`);
64
+ }
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ export function normalizeObserved(value, expectedHost) {
70
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
71
+ throw new CapabilityCacheError('observed descriptor must be a JSON object');
72
+ }
73
+ checkKeys(value, OBSERVED_KEYS, 'observed descriptor');
74
+ if (value.schema_version !== SCHEMA_VERSION) {
75
+ throw new CapabilityCacheError(`observed descriptor must declare schema_version ${SCHEMA_VERSION}`);
76
+ }
77
+ if (value.host !== expectedHost) {
78
+ throw new CapabilityCacheError(`observed descriptor host must be ${JSON.stringify(expectedHost)}`);
79
+ }
80
+ const hostVersion = value.host_version ?? 'unknown';
81
+ if (typeof hostVersion !== 'string' || !hostVersion) {
82
+ throw new CapabilityCacheError('observed descriptor host_version must be a string');
83
+ }
84
+ const tools = value.tools;
85
+ if (!Array.isArray(tools)) {
86
+ throw new CapabilityCacheError('observed descriptor tools must be an array');
87
+ }
88
+ const normalizedTools = [];
89
+ const names = new Set();
90
+ for (let index = 0; index < tools.length; index += 1) {
91
+ const raw = tools[index];
92
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
93
+ throw new CapabilityCacheError(`tools[${index}] must be a JSON object`);
94
+ }
95
+ checkKeys(raw, TOOL_KEYS, `tools[${index}]`);
96
+ const name = raw.name;
97
+ if (typeof name !== 'string' || !name || name.length > 256 || /[\x00-\x1f]/u.test(name) || names.has(name)) {
98
+ throw new CapabilityCacheError(`tools[${index}].name must be a unique string`);
99
+ }
100
+ names.add(name);
101
+ normalizedTools.push({
102
+ name,
103
+ parameters: stringList(raw.parameters === undefined ? [] : raw.parameters, `tools[${index}].parameters`),
104
+ returns: stringList(raw.returns === undefined ? [] : raw.returns, `tools[${index}].returns`),
105
+ });
106
+ }
107
+ const capabilities = flatMap(value.capabilities === undefined ? {} : value.capabilities, 'capabilities');
108
+ if (capabilities['model.discovery'] !== undefined && !MODEL_DISCOVERY_VALUES.has(capabilities['model.discovery'])) {
109
+ throw new CapabilityCacheError('capabilities.model.discovery must be available or unavailable');
110
+ }
111
+ return {
112
+ schema_version: SCHEMA_VERSION,
113
+ host: expectedHost,
114
+ host_version: hostVersion,
115
+ tools: normalizedTools.sort((a, b) => a.name.localeCompare(b.name)),
116
+ capabilities,
117
+ limits: flatMap(value.limits === undefined ? {} : value.limits, 'limits'),
118
+ unknown: stringList(value.unknown === undefined ? [] : value.unknown, 'unknown'),
119
+ };
120
+ }
121
+
122
+ export function capabilityFingerprint(observed) {
123
+ const interfaceData = { tools: observed.tools };
124
+ const payload = Buffer.from(JSON.stringify(interfaceData), 'utf8');
125
+ return `sha256:${createHash('sha256').update(payload).digest('hex')}`;
126
+ }
127
+
128
+ export function cacheRoot(repo, scope, explicit = null) {
129
+ if (explicit) return resolveUserPath(explicit);
130
+ return scope === 'global' ? userConfigDir() : projectConfigDir(resolvePath(repo));
131
+ }
132
+
133
+ export function snapshotPath(root, host) {
134
+ return join(root, 'capabilities', `${assertHost(host)}.json`);
135
+ }
136
+
137
+ export function observationsDir(root, host) {
138
+ return join(root, 'observations', assertHost(host));
139
+ }
140
+
141
+ function readJsonFile(path) {
142
+ try {
143
+ const stats = statSync(path);
144
+ if (stats.size > MAX_JSON_BYTES) {
145
+ throw new CapabilityCacheError(`${path} exceeds ${MAX_JSON_BYTES} bytes`);
146
+ }
147
+ return parseJsonStrict(readFileSync(path, 'utf8'));
148
+ } catch (error) {
149
+ if (error instanceof CapabilityCacheError) throw error;
150
+ throw new CapabilityCacheError(`cannot read ${path}: ${error.message}`);
151
+ }
152
+ }
153
+
154
+ export function atomicWrite(path, value) {
155
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
156
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
157
+ try {
158
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
159
+ renameSync(temp, path);
160
+ } finally {
161
+ try {
162
+ if (existsSync(temp)) unlinkSync(temp);
163
+ } catch {}
164
+ }
165
+ }
166
+
167
+ export function parseTime(value, label) {
168
+ if (typeof value !== 'string' || !ISO_TZ_PATTERN.test(value)) {
169
+ throw new CapabilityCacheError(`${label} must be a valid ISO-8601 string with timezone (e.g. 2026-08-01T12:00:00Z)`);
170
+ }
171
+ const timestamp = Date.parse(value);
172
+ if (Number.isNaN(timestamp)) {
173
+ throw new CapabilityCacheError(`${label} is not valid ISO-8601`);
174
+ }
175
+ return new Date(timestamp);
176
+ }
177
+
178
+ export function refreshSnapshot(root, host, observedValue, ttlHours = 168, now = null) {
179
+ if (!Number.isInteger(ttlHours) || ttlHours < 1 || ttlHours > 24 * 90) {
180
+ throw new CapabilityCacheError('ttl_hours must be an integer between 1 and 2160');
181
+ }
182
+ const current = now || new Date();
183
+ const observed = normalizeObserved(observedValue, assertHost(host));
184
+ const expires = new Date(current.getTime() + ttlHours * 3600 * 1000);
185
+ const snapshot = {
186
+ schema_version: SCHEMA_VERSION,
187
+ host,
188
+ host_version: observed.host_version,
189
+ generated_at: current.toISOString(),
190
+ expires_at: expires.toISOString(),
191
+ capability_fingerprint: capabilityFingerprint(observed),
192
+ source: 'live-tool-schema',
193
+ observed,
194
+ };
195
+ const path = snapshotPath(root, host);
196
+ try {
197
+ atomicWrite(path, snapshot);
198
+ } catch (error) {
199
+ return {
200
+ status: 'write-blocked',
201
+ snapshot_path: path,
202
+ error: error.message,
203
+ candidate_snapshot: snapshot,
204
+ };
205
+ }
206
+ return { status: 'refreshed', snapshot_path: path, snapshot };
207
+ }
208
+
209
+ export function inspectSnapshot(root, host, observedValue, now = null) {
210
+ const observed = normalizeObserved(observedValue, assertHost(host));
211
+ const path = snapshotPath(root, host);
212
+ const base = {
213
+ snapshot_path: path,
214
+ observations_path: observationsDir(root, host),
215
+ current_fingerprint: capabilityFingerprint(observed),
216
+ };
217
+ if (!existsSync(path)) {
218
+ return { ...base, status: 'absent', refresh_required: true, reasons: ['snapshot-missing'] };
219
+ }
220
+ const current = now || new Date();
221
+ try {
222
+ const snapshot = readJsonFile(path);
223
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
224
+ throw new CapabilityCacheError('snapshot must be a JSON object');
225
+ }
226
+ checkKeys(snapshot, SNAPSHOT_KEYS, 'snapshot');
227
+ if (snapshot.schema_version !== SCHEMA_VERSION) {
228
+ throw new CapabilityCacheError('snapshot schema_version is unsupported');
229
+ }
230
+ if (snapshot.host !== host) {
231
+ throw new CapabilityCacheError('snapshot host does not match');
232
+ }
233
+ const cachedObserved = normalizeObserved(snapshot.observed, host);
234
+ if (snapshot.host_version !== cachedObserved.host_version) {
235
+ throw new CapabilityCacheError('snapshot host_version does not match its observed descriptor');
236
+ }
237
+ if (snapshot.capability_fingerprint !== capabilityFingerprint(cachedObserved)) {
238
+ throw new CapabilityCacheError('snapshot fingerprint does not match its observed descriptor');
239
+ }
240
+ if (snapshot.source !== 'live-tool-schema') {
241
+ throw new CapabilityCacheError('snapshot source must be live-tool-schema');
242
+ }
243
+ const expiresAt = parseTime(snapshot.expires_at, 'snapshot.expires_at');
244
+ const generatedAt = parseTime(snapshot.generated_at, 'snapshot.generated_at');
245
+ if (generatedAt.getTime() > current.getTime() + 5 * 60 * 1000) {
246
+ throw new CapabilityCacheError('snapshot generated_at is in the future');
247
+ }
248
+ if (expiresAt.getTime() <= generatedAt.getTime() || expiresAt.getTime() - generatedAt.getTime() > 24 * 90 * 3600 * 1000) {
249
+ throw new CapabilityCacheError('snapshot validity window is invalid');
250
+ }
251
+ const reasons = [];
252
+ if (current.getTime() >= expiresAt.getTime()) {
253
+ reasons.push('snapshot-expired');
254
+ }
255
+ if (snapshot.host_version !== observed.host_version) {
256
+ reasons.push('host-version-changed');
257
+ }
258
+ if (snapshot.capability_fingerprint !== base.current_fingerprint) {
259
+ reasons.push('live-capability-fingerprint-changed');
260
+ }
261
+ return {
262
+ ...base,
263
+ status: reasons.length ? 'stale' : 'fresh',
264
+ refresh_required: reasons.length > 0,
265
+ reasons,
266
+ snapshot,
267
+ };
268
+ } catch (error) {
269
+ return { ...base, status: 'stale', refresh_required: true, reasons: [`snapshot-invalid: ${error.message}`] };
270
+ }
271
+ }
272
+
273
+ export function recordObservation(root, host, value, now = null) {
274
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
275
+ throw new CapabilityCacheError('observation must be a JSON object');
276
+ }
277
+ checkKeys(value, EVENT_KEYS, 'observation');
278
+ if (value.schema_version !== SCHEMA_VERSION) {
279
+ throw new CapabilityCacheError(`observation must declare schema_version ${SCHEMA_VERSION}`);
280
+ }
281
+ const category = value.category;
282
+ if (typeof category !== 'string' || !KEY_PATTERN.test(category)) {
283
+ throw new CapabilityCacheError('observation.category is invalid');
284
+ }
285
+ const summary = value.summary;
286
+ if (typeof summary !== 'string' || !summary.trim() || summary.length > 2000) {
287
+ throw new CapabilityCacheError('observation.summary must be 1-2000 characters');
288
+ }
289
+ const confidence = value.confidence;
290
+ if (!CONFIDENCE_VALUES.has(confidence)) {
291
+ throw new CapabilityCacheError('observation.confidence is invalid');
292
+ }
293
+ const evidence = flatMap(value.evidence || {}, 'observation.evidence');
294
+ const portable = value.portable === true;
295
+ if (value.portable !== undefined && typeof value.portable !== 'boolean') {
296
+ throw new CapabilityCacheError('observation.portable must be boolean');
297
+ }
298
+ const current = now || new Date();
299
+ const snapshotFile = snapshotPath(root, assertHost(host));
300
+ let fingerprint = null;
301
+ if (existsSync(snapshotFile)) {
302
+ try {
303
+ const cached = readJsonFile(snapshotFile);
304
+ if (cached && typeof cached.capability_fingerprint === 'string' && /^sha256:[0-9a-f]{64}$/u.test(cached.capability_fingerprint)) {
305
+ fingerprint = cached.capability_fingerprint;
306
+ }
307
+ } catch {}
308
+ }
309
+ const record = {
310
+ schema_version: SCHEMA_VERSION,
311
+ host,
312
+ recorded_at: current.toISOString(),
313
+ capability_fingerprint: fingerprint,
314
+ event: {
315
+ category,
316
+ summary: summary.trim(),
317
+ confidence,
318
+ evidence,
319
+ portable,
320
+ },
321
+ };
322
+ const directory = observationsDir(root, host);
323
+ const filename = `${current.toISOString().replace(/[:.]/gu, '')}-${randomUUID()}.json`;
324
+ const path = join(directory, filename);
325
+ try {
326
+ atomicWrite(path, record);
327
+ } catch (error) {
328
+ return {
329
+ status: 'write-blocked',
330
+ observation_path: path,
331
+ error: error.message,
332
+ candidate_record: record,
333
+ };
334
+ }
335
+ return { status: 'recorded', observation_path: path, record };
336
+ }
337
+
338
+ export function parseCli(argv = process.argv.slice(2)) {
339
+ if (!argv.length) {
340
+ throw new CapabilityCacheError('missing command: status, refresh, or observe');
341
+ }
342
+ const command = argv[0];
343
+ if (!['status', 'refresh', 'observe'].includes(command)) {
344
+ throw new CapabilityCacheError('command must be status, refresh, or observe');
345
+ }
346
+ const options = { command, host: '', repo: '.', scope: 'global', config_dir: null, observed: null, ttl_hours: 168, event: null };
347
+ for (let i = 1; i < argv.length; i += 1) {
348
+ const arg = argv[i];
349
+ if (arg === '--host') {
350
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --host 参数值');
351
+ options.host = argv[++i];
352
+ } else if (arg === '--repo') {
353
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --repo 参数值');
354
+ options.repo = argv[++i];
355
+ } else if (arg === '--scope') {
356
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --scope 参数值');
357
+ options.scope = argv[++i];
358
+ } else if (arg === '--config-dir') {
359
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --config-dir 参数值');
360
+ options.config_dir = argv[++i];
361
+ } else if (arg === '--observed') {
362
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --observed 参数值');
363
+ options.observed = argv[++i];
364
+ } else if (arg === '--ttl-hours') {
365
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --ttl-hours 参数值');
366
+ const raw = argv[++i];
367
+ const parsed = Number(raw);
368
+ if (!Number.isInteger(parsed) || !/^-?\d+$/u.test(raw)) {
369
+ throw new CapabilityCacheError(`--ttl-hours must be an integer, got ${JSON.stringify(raw)}`);
370
+ }
371
+ options.ttl_hours = parsed;
372
+ } else if (arg === '--event') {
373
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new CapabilityCacheError('缺少 --event 参数值');
374
+ options.event = argv[++i];
375
+ } else {
376
+ throw new CapabilityCacheError(`unknown option: ${arg}`);
377
+ }
378
+ }
379
+ if (!options.host) throw new CapabilityCacheError('缺少 --host');
380
+ assertHost(options.host);
381
+
382
+ if (!['global', 'project'].includes(options.scope)) {
383
+ throw new CapabilityCacheError(`scope must be "global" or "project", got ${JSON.stringify(options.scope)}`);
384
+ }
385
+ if (options.command === 'status' && !options.observed) {
386
+ throw new CapabilityCacheError('status command requires --observed <path>');
387
+ }
388
+ if (options.command === 'refresh') {
389
+ if (!options.observed) {
390
+ throw new CapabilityCacheError('refresh command requires --observed <path>');
391
+ }
392
+ if (options.ttl_hours < 1 || options.ttl_hours > 2160) {
393
+ throw new CapabilityCacheError(`--ttl-hours must be between 1 and 2160, got ${options.ttl_hours}`);
394
+ }
395
+ }
396
+ if (options.command === 'observe' && !options.event) {
397
+ throw new CapabilityCacheError('observe command requires --event <path>');
398
+ }
399
+ return options;
400
+ }
401
+
402
+ export function main(argv = process.argv.slice(2)) {
403
+ const options = parseCli(argv);
404
+ const root = cacheRoot(options.repo, options.scope, options.config_dir);
405
+ let result;
406
+ if (options.command === 'status') {
407
+ result = inspectSnapshot(root, options.host, readJsonFile(resolveUserPath(options.observed)));
408
+ } else if (options.command === 'refresh') {
409
+ result = refreshSnapshot(root, options.host, readJsonFile(resolveUserPath(options.observed)), options.ttl_hours);
410
+ } else {
411
+ result = recordObservation(root, options.host, readJsonFile(resolveUserPath(options.event)));
412
+ }
413
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
414
+ return 0;
415
+ }
416
+
417
+ function entry() {
418
+ try {
419
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
420
+ } catch {
421
+ return pathToFileURL(resolvePath(process.argv[1] ?? '')).href === import.meta.url;
422
+ }
423
+ }
424
+
425
+ export function runCli(argv = process.argv.slice(2)) {
426
+ try {
427
+ return main(argv);
428
+ } catch (error) {
429
+ if (error instanceof CapabilityCacheError) {
430
+ process.stderr.write(`host capability cache error: ${error.message}\n`);
431
+ return 2;
432
+ }
433
+ throw error;
434
+ }
435
+ }
436
+
437
+ if (entry()) process.exitCode = runCli();