@birdie_moblie/open_spec 2.1.7
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.
- package/.agents/plugins/marketplace.json +13 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/bin/openspec.js +3 -0
- package/dist/cli/commands/change.d.ts +3 -0
- package/dist/cli/commands/change.js +37 -0
- package/dist/cli/commands/ext.d.ts +3 -0
- package/dist/cli/commands/ext.js +196 -0
- package/dist/cli/commands/figma.d.ts +3 -0
- package/dist/cli/commands/figma.js +73 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.js +71 -0
- package/dist/cli/commands/opsx.d.ts +3 -0
- package/dist/cli/commands/opsx.js +324 -0
- package/dist/cli/commands/usage.d.ts +3 -0
- package/dist/cli/commands/usage.js +21 -0
- package/dist/cli/output.d.ts +8 -0
- package/dist/cli/output.js +17 -0
- package/dist/cli/program.d.ts +4 -0
- package/dist/cli/program.js +42 -0
- package/dist/cli/register.d.ts +3 -0
- package/dist/cli/register.js +13 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/config/load.d.ts +7 -0
- package/dist/config/load.js +55 -0
- package/dist/config/presets.d.ts +129 -0
- package/dist/config/presets.js +125 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/schema.js +178 -0
- package/dist/core/archive.d.ts +5 -0
- package/dist/core/archive.js +33 -0
- package/dist/core/change.d.ts +12 -0
- package/dist/core/change.js +69 -0
- package/dist/core/code-changes.d.ts +9 -0
- package/dist/core/code-changes.js +31 -0
- package/dist/core/flow.d.ts +6 -0
- package/dist/core/flow.js +99 -0
- package/dist/core/gates.d.ts +5 -0
- package/dist/core/gates.js +71 -0
- package/dist/core/instructions.d.ts +21 -0
- package/dist/core/instructions.js +102 -0
- package/dist/core/source-operations.d.ts +25 -0
- package/dist/core/source-operations.js +94 -0
- package/dist/core/sources.d.ts +33 -0
- package/dist/core/sources.js +234 -0
- package/dist/core/state.d.ts +15 -0
- package/dist/core/state.js +75 -0
- package/dist/core/status.d.ts +31 -0
- package/dist/core/status.js +22 -0
- package/dist/core/tasks.d.ts +15 -0
- package/dist/core/tasks.js +143 -0
- package/dist/core/todo.d.ts +31 -0
- package/dist/core/todo.js +114 -0
- package/dist/core/types.d.ts +297 -0
- package/dist/core/types.js +227 -0
- package/dist/core/verify.d.ts +229 -0
- package/dist/core/verify.js +391 -0
- package/dist/core/worker-evidence.d.ts +71 -0
- package/dist/core/worker-evidence.js +253 -0
- package/dist/core/worker.d.ts +22 -0
- package/dist/core/worker.js +478 -0
- package/dist/delivery/claude-install.d.ts +4 -0
- package/dist/delivery/claude-install.js +44 -0
- package/dist/delivery/distribution.d.ts +3 -0
- package/dist/delivery/distribution.js +53 -0
- package/dist/delivery/skills.d.ts +36 -0
- package/dist/delivery/skills.js +178 -0
- package/dist/figma/cache.d.ts +19 -0
- package/dist/figma/cache.js +115 -0
- package/dist/figma/claude.d.ts +14 -0
- package/dist/figma/claude.js +121 -0
- package/dist/figma/collect.d.ts +3 -0
- package/dist/figma/collect.js +57 -0
- package/dist/figma/index.d.ts +35 -0
- package/dist/figma/index.js +178 -0
- package/dist/figma/media.d.ts +5 -0
- package/dist/figma/media.js +77 -0
- package/dist/figma/response.d.ts +9 -0
- package/dist/figma/response.js +27 -0
- package/dist/hooks/command-context.d.ts +19 -0
- package/dist/hooks/command-context.js +33 -0
- package/dist/hooks/engine.d.ts +61 -0
- package/dist/hooks/engine.js +314 -0
- package/dist/hooks/plugin-runtime.d.ts +188 -0
- package/dist/hooks/plugin-runtime.js +198 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +185 -0
- package/dist/marketplace/adapters.d.ts +202 -0
- package/dist/marketplace/adapters.js +203 -0
- package/dist/marketplace/builtin.d.ts +9 -0
- package/dist/marketplace/builtin.js +9 -0
- package/dist/marketplace/install.d.ts +38 -0
- package/dist/marketplace/install.js +108 -0
- package/dist/marketplace/registry.d.ts +71 -0
- package/dist/marketplace/registry.js +342 -0
- package/dist/marketplace/upgrade.d.ts +39 -0
- package/dist/marketplace/upgrade.js +104 -0
- package/dist/shared/command-log.d.ts +9 -0
- package/dist/shared/command-log.js +55 -0
- package/dist/shared/errors.d.ts +7 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/exec.d.ts +16 -0
- package/dist/shared/exec.js +86 -0
- package/dist/shared/fs.d.ts +18 -0
- package/dist/shared/fs.js +93 -0
- package/dist/shared/git.d.ts +22 -0
- package/dist/shared/git.js +132 -0
- package/dist/shared/hash.d.ts +5 -0
- package/dist/shared/hash.js +29 -0
- package/dist/shared/paths.d.ts +16 -0
- package/dist/shared/paths.js +97 -0
- package/dist/shared/pkg.d.ts +3 -0
- package/dist/shared/pkg.js +23 -0
- package/dist/shared/version.d.ts +9 -0
- package/dist/shared/version.js +34 -0
- package/dist/usage/analyze.d.ts +5 -0
- package/dist/usage/analyze.js +212 -0
- package/dist/usage/claude.d.ts +9 -0
- package/dist/usage/claude.js +59 -0
- package/dist/usage/collect.d.ts +11 -0
- package/dist/usage/collect.js +71 -0
- package/dist/usage/doctor.d.ts +72 -0
- package/dist/usage/doctor.js +121 -0
- package/dist/usage/parse.d.ts +28 -0
- package/dist/usage/parse.js +345 -0
- package/dist/usage/types.d.ts +103 -0
- package/dist/usage/types.js +2 -0
- package/docs/getting-started.md +20 -0
- package/docs/guides/configuration.md +33 -0
- package/docs/guides/cost-benchmark.md +88 -0
- package/docs/guides/plugin-migration.md +65 -0
- package/docs/guides/smart-figma.md +51 -0
- package/docs/guides/source-reading.md +35 -0
- package/docs/guides/ui-conventions-template.md +39 -0
- package/docs/guides/worker-evidence.md +73 -0
- package/docs/guides/workflow.md +74 -0
- package/docs/maintainers/architecture.md +27 -0
- package/docs/maintainers/plugin-runtime-contract.md +55 -0
- package/package.json +78 -0
- package/plugin.json +9 -0
- package/presets/backend-service.yaml +19 -0
- package/presets/flutter-mobile/plugin-hooks.json +68 -0
- package/presets/flutter-mobile/ui-conventions.md +122 -0
- package/presets/flutter-mobile.yaml +205 -0
- package/presets/web-product.yaml +23 -0
- package/skills/opsx-apply/SKILL.md +35 -0
- package/skills/opsx-apply/nodes/blocked.md +7 -0
- package/skills/opsx-apply/nodes/code-task.md +29 -0
- package/skills/opsx-apply/nodes/gate-a.md +9 -0
- package/skills/opsx-apply/nodes/stop.md +5 -0
- package/skills/opsx-apply/nodes/task-build.md +50 -0
- package/skills/opsx-apply/nodes/task-repair.md +20 -0
- package/skills/opsx-apply/nodes/task-scout.md +28 -0
- package/skills/opsx-apply/nodes/task-verify.md +38 -0
- package/skills/opsx-archive/SKILL.md +24 -0
- package/skills/opsx-archive/nodes/archive.md +15 -0
- package/skills/opsx-explore/SKILL.md +20 -0
- package/skills/opsx-explore/nodes/explore.md +3 -0
- package/skills/opsx-fix-verify/SKILL.md +30 -0
- package/skills/opsx-fix-verify/nodes/repair.md +11 -0
- package/skills/opsx-fix-verify/nodes/reverify.md +3 -0
- package/skills/opsx-fix-verify/nodes/stop.md +3 -0
- package/skills/opsx-gate-decision/SKILL.md +20 -0
- package/skills/opsx-gate-decision/nodes/decide.md +15 -0
- package/skills/opsx-propose/SKILL.md +36 -0
- package/skills/opsx-propose/nodes/blocked.md +13 -0
- package/skills/opsx-propose/nodes/collect.md +35 -0
- package/skills/opsx-propose/nodes/parse.md +72 -0
- package/skills/opsx-propose/nodes/plan.md +65 -0
- package/skills/opsx-propose/nodes/stop.md +17 -0
- package/skills/opsx-propose/nodes/tech.md +77 -0
- package/skills/opsx-propose/scripts/read-json-source.mjs +22 -0
- package/skills/opsx-resume/SKILL.md +21 -0
- package/skills/opsx-resume/nodes/route.md +12 -0
- package/skills/opsx-source-update/SKILL.md +23 -0
- package/skills/opsx-source-update/nodes/update.md +14 -0
- package/skills/opsx-verify/SKILL.md +32 -0
- package/skills/opsx-verify/nodes/blocked.md +3 -0
- package/skills/opsx-verify/nodes/failed.md +3 -0
- package/skills/opsx-verify/nodes/gate-b.md +7 -0
- package/skills/opsx-verify/nodes/stop.md +5 -0
- package/skills/opsx-verify/nodes/verify.md +10 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { exists, readJson, readText, writeJson, writeText } from '../shared/fs.js';
|
|
4
|
+
import { digestDirectory, sha256 } from '../shared/hash.js';
|
|
5
|
+
import { AppError } from '../shared/errors.js';
|
|
6
|
+
import { packageRoot } from '../shared/pkg.js';
|
|
7
|
+
import { execFile } from '../shared/exec.js';
|
|
8
|
+
import { WORKFLOWS } from '../core/types.js';
|
|
9
|
+
import { resolveInside } from '../shared/paths.js';
|
|
10
|
+
import { preparePluginDistribution } from './distribution.js';
|
|
11
|
+
import { installClaudePlugin } from './claude-install.js';
|
|
12
|
+
const DeliveryResultSchema = z.object({
|
|
13
|
+
host: z.enum(['claude', 'codex', 'cursor']),
|
|
14
|
+
status: z.enum(['installed', 'pending_user_action', 'skipped_by_user', 'failed']),
|
|
15
|
+
digest: z.string(),
|
|
16
|
+
skills: z.array(z.string()),
|
|
17
|
+
target: z.string(),
|
|
18
|
+
error: z.string().optional(),
|
|
19
|
+
}).strict();
|
|
20
|
+
const DeliveryReceiptSchema = z.object({
|
|
21
|
+
deliveredAt: z.string(),
|
|
22
|
+
results: z.array(DeliveryResultSchema),
|
|
23
|
+
}).strict();
|
|
24
|
+
export function deliverSkills(projectRoot, hosts, mode) {
|
|
25
|
+
const source = path.join(packageRoot(), 'skills');
|
|
26
|
+
if (!exists(source))
|
|
27
|
+
throw new AppError('skills_missing', '本包缺少 skills/ 目录');
|
|
28
|
+
const skills = WORKFLOWS.filter((name) => exists(path.join(source, name)));
|
|
29
|
+
const results = hosts.map((host) => DeliveryResultSchema.parse(deliverHost(projectRoot, host, mode, digestDelivery(packageRoot(), host), skills)));
|
|
30
|
+
writeJson(resolveInside(projectRoot, 'openspec/.runtime/delivery.json', 'delivery_path_outside_project'), {
|
|
31
|
+
deliveredAt: new Date().toISOString(),
|
|
32
|
+
results,
|
|
33
|
+
});
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
function deliverHost(projectRoot, host, mode, digest, skills) {
|
|
37
|
+
const selector = 'openspec@openspec-bridge';
|
|
38
|
+
if (mode === 'skip')
|
|
39
|
+
return { host, status: 'skipped_by_user', digest, skills, target: selector };
|
|
40
|
+
if (host === 'cursor' || mode === 'pending') {
|
|
41
|
+
const target = host === 'cursor' ? 'Cursor Customize → Plugins → openspec' : selector;
|
|
42
|
+
return { host, status: 'pending_user_action', digest, skills, target };
|
|
43
|
+
}
|
|
44
|
+
const root = preparePluginDistribution(packageRoot());
|
|
45
|
+
// Claude 用 local scope(.claude/settings.local.json,不入库):marketplace 路径是本机绝对路径,不能随 project scope 提交给他人
|
|
46
|
+
const add = host === 'claude'
|
|
47
|
+
? execFile('claude', ['plugin', 'marketplace', 'add', root, '--scope', 'local'], { cwd: projectRoot })
|
|
48
|
+
: execFile('codex', ['plugin', 'marketplace', 'add', root, '--json'], { cwd: projectRoot });
|
|
49
|
+
if (!add.ok)
|
|
50
|
+
return { host, status: 'failed', digest, skills, target: selector, error: add.stderr || add.stdout };
|
|
51
|
+
const install = host === 'claude'
|
|
52
|
+
? installClaudePlugin(projectRoot, selector, root)
|
|
53
|
+
: execFile('codex', ['plugin', 'add', selector, '--json'], { cwd: projectRoot });
|
|
54
|
+
return install.ok
|
|
55
|
+
? { host, status: 'installed', digest, skills, target: selector }
|
|
56
|
+
: { host, status: 'failed', digest, skills, target: selector, error: install.stderr || install.stdout };
|
|
57
|
+
}
|
|
58
|
+
export function verifyDelivery(projectRoot, host) {
|
|
59
|
+
const expected = digestDelivery(packageRoot(), host);
|
|
60
|
+
const file = resolveInside(projectRoot, 'openspec/.runtime/delivery.json', 'delivery_path_outside_project');
|
|
61
|
+
if (!exists(file))
|
|
62
|
+
return { ok: false, status: 'missing', expected, actual: '' };
|
|
63
|
+
const parsed = DeliveryReceiptSchema.safeParse(readJson(file));
|
|
64
|
+
if (!parsed.success)
|
|
65
|
+
throw new AppError('invalid_delivery_receipt', '宿主交付记录不合法', parsed.error.issues);
|
|
66
|
+
const record = parsed.data.results.find((item) => item.host === host);
|
|
67
|
+
if (!record)
|
|
68
|
+
return { ok: false, status: 'missing', expected, actual: '' };
|
|
69
|
+
return {
|
|
70
|
+
ok: record.status === 'installed' && record.digest === expected,
|
|
71
|
+
status: record.status,
|
|
72
|
+
expected,
|
|
73
|
+
actual: record.digest,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function digestDelivery(root, host) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
for (const name of WORKFLOWS) {
|
|
79
|
+
const skillDir = path.join(root, 'skills', name);
|
|
80
|
+
if (exists(skillDir))
|
|
81
|
+
parts.push(`${name}:${digestDirectory(skillDir)}`);
|
|
82
|
+
}
|
|
83
|
+
// Offline benchmarks also digest historical packages that contained host helpers.
|
|
84
|
+
const extras = path.join(root, 'host-skills', host);
|
|
85
|
+
if (exists(extras))
|
|
86
|
+
parts.push(`host:${digestDirectory(extras)}`);
|
|
87
|
+
for (const relative of ['plugin.json', '.agents', ...(host === 'claude' ? ['.claude-plugin'] : host === 'codex' ? ['.codex-plugin'] : [])]) {
|
|
88
|
+
const file = path.join(root, relative);
|
|
89
|
+
if (exists(file))
|
|
90
|
+
parts.push(`${relative}:${relative.endsWith('.json') ? sha256(readText(file)) : digestDirectory(file)}`);
|
|
91
|
+
}
|
|
92
|
+
return sha256(parts.join('|'));
|
|
93
|
+
}
|
|
94
|
+
/** Entire executable artifact, excluding installation dependencies and local state. */
|
|
95
|
+
export function digestPackage(root) {
|
|
96
|
+
return sha256(['dist', 'bin', 'skills', 'host-skills', 'presets', 'docs', '.agents', '.codex-plugin', '.claude-plugin',
|
|
97
|
+
'package.json', 'plugin.json', 'README.md'].map((relative) => {
|
|
98
|
+
const file = path.join(root, relative);
|
|
99
|
+
if (!exists(file))
|
|
100
|
+
return `${relative}:missing`;
|
|
101
|
+
return `${relative}:${relative.includes('.') && !relative.startsWith('.') ? sha256(readText(file)) : digestDirectory(file)}`;
|
|
102
|
+
}).join('|'));
|
|
103
|
+
}
|
|
104
|
+
export function generateInstallCli(version, packageName) {
|
|
105
|
+
return `#!/usr/bin/env node
|
|
106
|
+
// @openspec-installer ${JSON.stringify({ packageName, packageVersion: version })}
|
|
107
|
+
import { spawnSync } from 'node:child_process';
|
|
108
|
+
|
|
109
|
+
const meta = { packageName: ${JSON.stringify(packageName)}, packageVersion: ${JSON.stringify(version)} };
|
|
110
|
+
const check = process.argv.includes('--check');
|
|
111
|
+
|
|
112
|
+
function fail(message) {
|
|
113
|
+
console.error(message);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
118
|
+
if (nodeMajor !== 22 && nodeMajor !== 24) {
|
|
119
|
+
fail('OpenSpec 需要 Node.js 22 或 24,当前为 ' + process.versions.node);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const npmCheck = spawnSync('npm', ['--version'], { encoding: 'utf8' });
|
|
123
|
+
if (npmCheck.status !== 0) fail('未找到可用的 npm,无法校验或安装 OpenSpec CLI');
|
|
124
|
+
|
|
125
|
+
function installedVersion() {
|
|
126
|
+
const result = spawnSync('openspec', ['--version'], { encoding: 'utf8' });
|
|
127
|
+
if (result.status !== 0) return null;
|
|
128
|
+
return (result.stdout || '').trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const current = installedVersion();
|
|
132
|
+
if (check) {
|
|
133
|
+
const ok = current === meta.packageVersion;
|
|
134
|
+
console.log(JSON.stringify({ ok, current, expected: meta.packageVersion, node: process.versions.node, npm: (npmCheck.stdout || '').trim() }));
|
|
135
|
+
process.exit(ok ? 0 : 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (current === meta.packageVersion) {
|
|
139
|
+
console.log(\`OpenSpec \${meta.packageVersion} 已安装\`);
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const install = spawnSync(
|
|
144
|
+
'npm',
|
|
145
|
+
['install', '--global', \`\${meta.packageName}@\${meta.packageVersion}\`, '--no-audit', '--no-fund'],
|
|
146
|
+
{ stdio: 'inherit' },
|
|
147
|
+
);
|
|
148
|
+
if (install.status !== 0) process.exit(install.status ?? 1);
|
|
149
|
+
const installed = installedVersion();
|
|
150
|
+
if (installed !== meta.packageVersion) {
|
|
151
|
+
fail('安装后版本校验失败:期望 ' + meta.packageVersion + ',实际 ' + (installed ?? 'missing'));
|
|
152
|
+
}
|
|
153
|
+
console.log('OpenSpec ' + meta.packageVersion + ' 安装并校验完成');
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
export function writeInstallCli(projectRoot, version, packageName) {
|
|
157
|
+
writeText(resolveInside(projectRoot, 'openspec/install-cli.mjs', 'installer_path_outside_project'), generateInstallCli(version, packageName));
|
|
158
|
+
}
|
|
159
|
+
export function verifyInstallCli(projectRoot, version, packageName) {
|
|
160
|
+
const expected = { packageName, packageVersion: version };
|
|
161
|
+
const file = resolveInside(projectRoot, 'openspec/install-cli.mjs', 'installer_path_outside_project');
|
|
162
|
+
if (!exists(file))
|
|
163
|
+
return { ok: false, expected, actual: null };
|
|
164
|
+
const firstLine = readText(file).split('\n').find((line) => line.startsWith('// @openspec-installer '));
|
|
165
|
+
if (!firstLine)
|
|
166
|
+
return { ok: false, expected, actual: null };
|
|
167
|
+
try {
|
|
168
|
+
const actual = JSON.parse(firstLine.slice('// @openspec-installer '.length));
|
|
169
|
+
return { ok: actual.packageName === packageName && actual.packageVersion === version, expected, actual };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { ok: false, expected, actual: null };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function isWorkflow(value) {
|
|
176
|
+
return WORKFLOWS.includes(value);
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=skills.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type PageIndex } from './index.js';
|
|
2
|
+
import type { SourceItem } from '../core/types.js';
|
|
3
|
+
interface CacheRecord {
|
|
4
|
+
epoch: string;
|
|
5
|
+
key: string;
|
|
6
|
+
indexRel: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function figmaEpoch(root: string, change: string): string;
|
|
9
|
+
export declare function cachedIndexes(root: string, change: string): Array<CacheRecord & {
|
|
10
|
+
index: PageIndex;
|
|
11
|
+
}>;
|
|
12
|
+
/** After collect, the ledger owns the snapshot; the collection cache epoch has ended. */
|
|
13
|
+
export declare function collectedIndexes(root: string, change: string, sources: SourceItem[]): PageIndex[];
|
|
14
|
+
export declare function persistIndex(root: string, change: string, key: string, index: PageIndex): string;
|
|
15
|
+
export declare function refreshFigma(root: string, change: string): void;
|
|
16
|
+
export declare function hasFigmaSession(root: string, change: string): boolean;
|
|
17
|
+
export declare function writeOnce(file: string, text: string): void;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, linkSync, unlinkSync, renameSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { artifactPath, requireChange } from '../core/change.js';
|
|
5
|
+
import { sha256 } from '../shared/hash.js';
|
|
6
|
+
import { figmaSource, projectMetadata } from './index.js';
|
|
7
|
+
const MANIFEST = 'sources/figma/cache.jsonl';
|
|
8
|
+
export function figmaEpoch(root, change) {
|
|
9
|
+
const state = requireChange(root, change);
|
|
10
|
+
return `${state.collectedRevision}:${state.sourceUpdate?.startedAt ?? ''}`;
|
|
11
|
+
}
|
|
12
|
+
export function cachedIndexes(root, change) {
|
|
13
|
+
const manifest = artifactPath(root, change, MANIFEST);
|
|
14
|
+
if (!existsSync(manifest))
|
|
15
|
+
return [];
|
|
16
|
+
let records = [];
|
|
17
|
+
const epoch = figmaEpoch(root, change);
|
|
18
|
+
for (const line of readFileSync(manifest, 'utf8').split('\n').filter(Boolean)) {
|
|
19
|
+
const row = JSON.parse(line);
|
|
20
|
+
if (row.refresh)
|
|
21
|
+
records = [];
|
|
22
|
+
else if (row.epoch === epoch)
|
|
23
|
+
records.push(row);
|
|
24
|
+
}
|
|
25
|
+
const latest = new Map(records.map(row => [row.key, row]));
|
|
26
|
+
return [...latest.values()].flatMap(record => {
|
|
27
|
+
if (!/^sources\/figma\/[a-f0-9]{64}\.index\.json$/.test(record.indexRel))
|
|
28
|
+
return [];
|
|
29
|
+
const index = readIndex(root, change, record.indexRel);
|
|
30
|
+
return index ? [{ ...record, index }] : [];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** After collect, the ledger owns the snapshot; the collection cache epoch has ended. */
|
|
34
|
+
export function collectedIndexes(root, change, sources) {
|
|
35
|
+
return sources.flatMap(source => {
|
|
36
|
+
if (!source.artifact || !source.artifactSha256)
|
|
37
|
+
return [];
|
|
38
|
+
try {
|
|
39
|
+
const text = readFileSync(artifactPath(root, change, source.artifact), 'utf8');
|
|
40
|
+
if (sha256(text) !== source.artifactSha256)
|
|
41
|
+
return [];
|
|
42
|
+
const pages = JSON.parse(text);
|
|
43
|
+
if (pages.adapter !== 'smart-figma' || pages.source?.url !== figmaSource(source.locator).url
|
|
44
|
+
|| !Array.isArray(pages.indexes) || !pages.indexes.length)
|
|
45
|
+
return [];
|
|
46
|
+
const indexes = pages.indexes.map((relative) => typeof relative === 'string'
|
|
47
|
+
&& /^sources\/figma\/[a-f0-9]{64}\.index\.json$/.test(relative) ? readIndex(root, change, relative) : undefined);
|
|
48
|
+
return indexes.every((index) => index !== undefined) ? indexes : [];
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function persistIndex(root, change, key, index) {
|
|
56
|
+
const text = JSON.stringify(index, null, 2) + '\n';
|
|
57
|
+
const indexRel = `sources/figma/${sha256(text)}.index.json`;
|
|
58
|
+
const file = artifactPath(root, change, indexRel);
|
|
59
|
+
writeOnce(file, text);
|
|
60
|
+
const manifest = artifactPath(root, change, MANIFEST);
|
|
61
|
+
appendFileSync(manifest, JSON.stringify({ epoch: figmaEpoch(root, change), key, indexRel }) + '\n');
|
|
62
|
+
return indexRel;
|
|
63
|
+
}
|
|
64
|
+
export function refreshFigma(root, change) {
|
|
65
|
+
const manifest = artifactPath(root, change, MANIFEST);
|
|
66
|
+
if (existsSync(manifest))
|
|
67
|
+
appendFileSync(manifest, JSON.stringify({ refresh: true, at: new Date().toISOString() }) + '\n');
|
|
68
|
+
}
|
|
69
|
+
export function hasFigmaSession(root, change) {
|
|
70
|
+
return existsSync(artifactPath(root, change, '.opsx/figma-claude-settings.json')) || existsSync(artifactPath(root, change, MANIFEST));
|
|
71
|
+
}
|
|
72
|
+
export function writeOnce(file, text) {
|
|
73
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
writeFileSync(temp, text, { flag: 'wx', mode: 0o600 });
|
|
76
|
+
try {
|
|
77
|
+
linkSync(temp, file);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code !== 'EEXIST')
|
|
81
|
+
throw error;
|
|
82
|
+
const old = readFileSync(file, 'utf8');
|
|
83
|
+
if (old !== text) {
|
|
84
|
+
renameSync(file, `${file}.invalid-${sha256(old)}`); // Keep corruption evidence; a fresh response can repair the cache.
|
|
85
|
+
linkSync(temp, file);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
unlinkSync(temp);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function readIndex(root, change, relative) {
|
|
93
|
+
const file = artifactPath(root, change, relative);
|
|
94
|
+
if (!existsSync(file))
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
const text = readFileSync(file, 'utf8');
|
|
98
|
+
if (path.basename(file) !== `${sha256(text)}.index.json`)
|
|
99
|
+
return undefined;
|
|
100
|
+
const value = JSON.parse(text);
|
|
101
|
+
if (!/^[a-f0-9]{64}$/.test(value.responseSha256))
|
|
102
|
+
return undefined;
|
|
103
|
+
const raw = artifactPath(root, change, `sources/figma/raw/${value.responseSha256}.json`);
|
|
104
|
+
if (!existsSync(raw) || sha256(readFileSync(raw)) !== value.responseSha256)
|
|
105
|
+
return undefined;
|
|
106
|
+
const expected = projectMetadata(JSON.parse(readFileSync(raw, 'utf8')), value.source);
|
|
107
|
+
if (JSON.stringify(expected) !== JSON.stringify(value))
|
|
108
|
+
return undefined;
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface HookInput {
|
|
2
|
+
hook_event_name: string;
|
|
3
|
+
cwd: string;
|
|
4
|
+
tool_name: string;
|
|
5
|
+
tool_input?: Record<string, unknown>;
|
|
6
|
+
tool_response?: unknown;
|
|
7
|
+
tool_use_id?: string;
|
|
8
|
+
session_id?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Session-scoped transport, never a worker dispatcher or an authentication client. */
|
|
11
|
+
export declare function figmaHook(root: string, change: string, event: HookInput, minImagePixels?: number): Record<string, unknown>;
|
|
12
|
+
export declare function claudeFigmaSettings(cli: string, root: string, change: string, minImagePixels?: number): Record<string, unknown>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=claude.d.ts.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { artifactPath, artifactRel, requireChange } from '../core/change.js';
|
|
3
|
+
import { loadLedger } from '../core/sources.js';
|
|
4
|
+
import { sha256 } from '../shared/hash.js';
|
|
5
|
+
import { figmaSource, indexPage, normalizeNodeId, projectMetadata } from './index.js';
|
|
6
|
+
import { unwrapClaudeResponse } from './response.js';
|
|
7
|
+
import { cachedIndexes, collectedIndexes, figmaEpoch, persistIndex, writeOnce } from './cache.js';
|
|
8
|
+
import { compatibleImages, guardImageRead, imagePixelLimit } from './media.js';
|
|
9
|
+
const FRONT = new Set(['collect', 'parse', 'tech', 'plan', 'gate_a']);
|
|
10
|
+
const MCP = /^mcp__(?:plugin_figma_figma|figma)__(.+)$/;
|
|
11
|
+
/** Session-scoped transport, never a worker dispatcher or an authentication client. */
|
|
12
|
+
export function figmaHook(root, change, event, minImagePixels) {
|
|
13
|
+
if (realpathSync(event.cwd) !== realpathSync(root))
|
|
14
|
+
return {};
|
|
15
|
+
const state = requireChange(root, change);
|
|
16
|
+
if (minImagePixels !== undefined)
|
|
17
|
+
imagePixelLimit(minImagePixels);
|
|
18
|
+
const pre = event.hook_event_name === 'PreToolUse';
|
|
19
|
+
if (!pre && event.hook_event_name !== 'PostToolUse')
|
|
20
|
+
return {};
|
|
21
|
+
if (pre && event.tool_name === 'Read' && minImagePixels)
|
|
22
|
+
return guardImageRead(root, event.tool_input ?? {}, minImagePixels);
|
|
23
|
+
const tool = MCP.exec(event.tool_name)?.[1];
|
|
24
|
+
if (!tool)
|
|
25
|
+
return {};
|
|
26
|
+
const media = () => !pre && minImagePixels ? compatibleImages(root, change, event.tool_response, minImagePixels) : {};
|
|
27
|
+
if (!FRONT.has(state.node))
|
|
28
|
+
return media(); // Coding retains full details and usable screenshots.
|
|
29
|
+
const deny = (reason) => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } });
|
|
30
|
+
const replace = (value) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', updatedMCPToolOutput: [{ type: 'text', text: JSON.stringify(value) }] } });
|
|
31
|
+
const designSources = loadLedger(root, change, state.sourceRevision).items.filter(item => item.capability === 'design' && item.kind === 'url');
|
|
32
|
+
const sources = designSources
|
|
33
|
+
.flatMap(item => { try {
|
|
34
|
+
return [figmaSource(item.locator)];
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
} });
|
|
39
|
+
if (!sources.length)
|
|
40
|
+
return media(); // Only an explicitly configured image limit applies.
|
|
41
|
+
const input = event.tool_input ?? {};
|
|
42
|
+
const fileKeys = [...new Set(sources.map(source => source.fileKey))];
|
|
43
|
+
const fileKey = typeof input.fileKey === 'string' ? input.fileKey : fileKeys.length === 1 ? fileKeys[0] : undefined;
|
|
44
|
+
let nodeId;
|
|
45
|
+
try {
|
|
46
|
+
nodeId = typeof input.nodeId === 'string' && input.nodeId ? normalizeNodeId(input.nodeId) : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return pre ? deny('Smart Figma: nodeId 无效。') : replace({ ok: false, code: 'figma_node_invalid' });
|
|
50
|
+
}
|
|
51
|
+
if (state.node !== 'collect') {
|
|
52
|
+
const allowed = state.node === 'parse' ? ['get_metadata', 'get_screenshot']
|
|
53
|
+
: ['tech', 'plan'].includes(state.node) ? ['get_metadata', 'get_screenshot', 'get_design_context', 'get_variable_defs'] : [];
|
|
54
|
+
const known = fileKey && nodeId && collectedIndexes(root, change, designSources)
|
|
55
|
+
.some(index => index.source.fileKey === fileKey && index.candidates.some(node => node.id === nodeId));
|
|
56
|
+
if (!allowed.includes(tool) || !known)
|
|
57
|
+
return pre
|
|
58
|
+
? deny('Smart Figma: parse 仅确认形态;tech/plan 仅核对复用与修改范围。请定向读取当前已采集索引中的候选 frame;来源范围、摘要或节点未确认,不能展开其他容器。')
|
|
59
|
+
: replace({ ok: false, code: 'figma_outside_scope' });
|
|
60
|
+
return media(); // Targeted evidence stays complete; only incompatible image blocks change.
|
|
61
|
+
}
|
|
62
|
+
if (tool !== 'get_metadata')
|
|
63
|
+
return pre ? deny('Smart Figma: collect 只做页面定位;形态在 parse 确认,复用适配在 tech 核对,完整实现证据在 coding 读取。') : {};
|
|
64
|
+
const epoch = figmaEpoch(root, change);
|
|
65
|
+
const cacheKey = sha256(JSON.stringify({ adapterVersion: 1, fileKey, nodeId, epoch }));
|
|
66
|
+
const cache = cachedIndexes(root, change);
|
|
67
|
+
const cached = cache.find(record => record.key === cacheKey);
|
|
68
|
+
let source = sources.find(item => item.fileKey === fileKey && item.nodeId === nodeId);
|
|
69
|
+
if (!source && fileKey && nodeId) {
|
|
70
|
+
// Descend only into explicitly returned unresolved containers, never page internals.
|
|
71
|
+
for (const record of cache) {
|
|
72
|
+
const parent = record.index;
|
|
73
|
+
if (parent?.source.fileKey === fileKey && parent.unresolved.some(node => node.id === nodeId && ['page', 'canvas', 'section', 'document'].includes(node.type))) {
|
|
74
|
+
source = figmaSource(`https://www.figma.com/design/${fileKey}?node-id=${nodeId}`);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!source)
|
|
80
|
+
return pre ? deny('Smart Figma: 请求不在登记的设计范围或待展开容器内;请使用页面索引,不能展开页面子树。') : replace({ ok: false, code: 'figma_outside_scope' });
|
|
81
|
+
if (pre) {
|
|
82
|
+
if (cached)
|
|
83
|
+
return deny(`Smart Figma: 本次 collect 已取得该索引,不再抓取。${JSON.stringify({ indexPath: artifactRel(change, cached.indexRel), ...indexPage(cached.index, 0, 7500) })}`);
|
|
84
|
+
const parent = cache.find(row => row.index.source.fileKey === fileKey
|
|
85
|
+
&& (row.index.candidates.some(node => node.id === nodeId)
|
|
86
|
+
|| row.index.containers.some(node => node.id === nodeId && node.childrenObserved)));
|
|
87
|
+
if (parent && source.nodeId) {
|
|
88
|
+
const rawFile = artifactPath(root, change, `sources/figma/raw/${parent.index.responseSha256}.json`);
|
|
89
|
+
const projected = projectMetadata(JSON.parse(readFileSync(rawFile, 'utf8')), source);
|
|
90
|
+
const indexRel = persistIndex(root, change, cacheKey, projected);
|
|
91
|
+
return deny(`Smart Figma: 已有响应包含此范围,无需再次抓取。${JSON.stringify({ indexPath: artifactRel(change, indexRel), ...indexPage(projected, 0, 7500) })}`);
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
const response = unwrapClaudeResponse(event.tool_response, { root, sessionId: event.session_id });
|
|
96
|
+
const raw = JSON.stringify(response);
|
|
97
|
+
const rawRel = `sources/figma/raw/${sha256(raw)}.json`;
|
|
98
|
+
const rawFile = artifactPath(root, change, rawRel);
|
|
99
|
+
writeOnce(rawFile, raw);
|
|
100
|
+
try {
|
|
101
|
+
const index = projectMetadata(response, source);
|
|
102
|
+
const indexRel = persistIndex(root, change, cacheKey, index);
|
|
103
|
+
return replace({ ok: true, indexPath: artifactRel(change, indexRel), ...indexPage(index, 0, 7500) });
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
return replace({ ok: false, code: 'figma_index_failed', error: error.message,
|
|
107
|
+
evidence: artifactRel(change, rawRel), instruction: '采集未完成;保留错误,不把缺失索引当作空设计。' });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function claudeFigmaSettings(cli, root, change, minImagePixels) {
|
|
111
|
+
requireChange(root, change);
|
|
112
|
+
const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
113
|
+
const command = [process.execPath, cli, 'sources', 'figma', 'hook', '--project-root', root, '--change', change,
|
|
114
|
+
...(minImagePixels === undefined ? [] : ['--min-image-pixels', String(imagePixelLimit(minImagePixels))])].map(quote).join(' ');
|
|
115
|
+
const matcher = '^mcp__(plugin_figma_figma|figma)__.*$';
|
|
116
|
+
return { hooks: {
|
|
117
|
+
PreToolUse: [{ matcher: minImagePixels === undefined ? matcher : '^(Read|mcp__(plugin_figma_figma|figma)__.*)$', hooks: [{ type: 'command', command, timeout: 20 }] }],
|
|
118
|
+
PostToolUse: [{ matcher, hooks: [{ type: 'command', command, timeout: 20 }] }],
|
|
119
|
+
} };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=claude.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { artifactPath } from '../core/change.js';
|
|
2
|
+
import { sha256 } from '../shared/hash.js';
|
|
3
|
+
import { AppError } from '../shared/errors.js';
|
|
4
|
+
import { cachedIndexes, hasFigmaSession, writeOnce } from './cache.js';
|
|
5
|
+
import { FIGMA_ADAPTER_VERSION, figmaSource } from './index.js';
|
|
6
|
+
/** undefined = not opted in; null = scoped source incomplete, never covered by an unrelated design file. */
|
|
7
|
+
export function collectSmartFigma(root, change, locator) {
|
|
8
|
+
if (!hasFigmaSession(root, change))
|
|
9
|
+
return undefined;
|
|
10
|
+
let source;
|
|
11
|
+
try {
|
|
12
|
+
source = figmaSource(locator);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const cache = cachedIndexes(root, change).filter(row => row.index.source.fileKey === source.fileKey);
|
|
18
|
+
const first = cache.find(row => row.index.source.url === source.url);
|
|
19
|
+
if (!first)
|
|
20
|
+
return null;
|
|
21
|
+
const queue = [first];
|
|
22
|
+
const visited = new Set();
|
|
23
|
+
const parts = [];
|
|
24
|
+
while (queue.length) {
|
|
25
|
+
const row = queue.shift();
|
|
26
|
+
if (visited.has(row.indexRel))
|
|
27
|
+
continue;
|
|
28
|
+
visited.add(row.indexRel);
|
|
29
|
+
parts.push(row.index);
|
|
30
|
+
for (const node of row.index.unresolved) {
|
|
31
|
+
const expanded = cache.find(child => child.index.source.nodeId === node.id && child.index.source.url !== row.index.source.url);
|
|
32
|
+
if (!expanded)
|
|
33
|
+
return null;
|
|
34
|
+
if (visited.has(expanded.indexRel))
|
|
35
|
+
return null; // A cyclic/inconsistent response cannot prove complete coverage.
|
|
36
|
+
queue.push(expanded);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const nodes = new Map();
|
|
40
|
+
for (const part of parts)
|
|
41
|
+
for (const node of part.candidates) {
|
|
42
|
+
const old = nodes.get(node.id);
|
|
43
|
+
if (old && (old.name !== node.name || old.type !== node.type || JSON.stringify(old.bounds) !== JSON.stringify(node.bounds))) {
|
|
44
|
+
throw new AppError('figma_snapshot_conflict', `页面 ${node.id} 在采集响应中不一致,需刷新来源`);
|
|
45
|
+
}
|
|
46
|
+
nodes.set(node.id, node);
|
|
47
|
+
}
|
|
48
|
+
const artifact = { adapter: 'smart-figma', version: FIGMA_ADAPTER_VERSION, source, status: 'candidates',
|
|
49
|
+
candidates: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true })),
|
|
50
|
+
indexes: [...visited].sort(), evidence: parts.map(part => part.responseSha256),
|
|
51
|
+
note: '结构候选,不推断业务映射;parse 可确认形态,tech/plan 可定向核对复用与范围;完整实现证据在 coding 读取。' };
|
|
52
|
+
const text = JSON.stringify(artifact, null, 2) + '\n';
|
|
53
|
+
const relative = `sources/figma/pages-${sha256(text)}.json`;
|
|
54
|
+
writeOnce(artifactPath(root, change, relative), text);
|
|
55
|
+
return relative;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=collect.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const FIGMA_ADAPTER_VERSION = 2;
|
|
2
|
+
export interface FigmaSource {
|
|
3
|
+
url: string;
|
|
4
|
+
fileKey: string;
|
|
5
|
+
nodeId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DesignNode {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
parent?: string;
|
|
12
|
+
bounds?: number[];
|
|
13
|
+
childrenObserved: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface PageIndex {
|
|
16
|
+
adapter: 'smart-figma';
|
|
17
|
+
version: number;
|
|
18
|
+
source: FigmaSource;
|
|
19
|
+
responseSha256: string;
|
|
20
|
+
roots: DesignNode[];
|
|
21
|
+
candidates: DesignNode[];
|
|
22
|
+
containers: DesignNode[];
|
|
23
|
+
leaves: DesignNode[];
|
|
24
|
+
unresolved: DesignNode[];
|
|
25
|
+
status: 'candidates' | 'needs-container-expansion';
|
|
26
|
+
}
|
|
27
|
+
export declare function normalizeNodeId(value: string): string;
|
|
28
|
+
export declare function figmaSource(value: string): FigmaSource;
|
|
29
|
+
/** Parse only complete XML documents. Never infer an empty design from sparse metadata. */
|
|
30
|
+
export declare function projectMetadata(response: unknown, source: FigmaSource): PageIndex;
|
|
31
|
+
/** Bounded model view; full candidates remain in the machine artifact, with explicit pagination. */
|
|
32
|
+
export declare function indexPage(index: Omit<PageIndex, 'leaves'> & {
|
|
33
|
+
leaves?: DesignNode[];
|
|
34
|
+
}, offset?: number, budget?: number): Record<string, unknown>;
|
|
35
|
+
//# sourceMappingURL=index.d.ts.map
|