@mc-and-his-agents/loom-installer 0.1.1 → 0.1.5
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/README.md +2 -1
- package/dist/src/codex.js +1 -1
- package/dist/test/installer.test.js +167 -0
- package/package.json +1 -1
- package/payload/manifest.json +8 -8
- package/payload/plugin/loom/skills/README.md +33 -1
- package/payload/plugin/loom/skills/distribution-and-adapter-contract.md +5 -4
- package/payload/plugin/loom/skills/shared/scripts/loom_check.py +171 -37
package/README.md
CHANGED
package/dist/src/codex.js
CHANGED
|
@@ -41,7 +41,7 @@ function ensureMarketplace(targetRoot, force) {
|
|
|
41
41
|
}
|
|
42
42
|
const expectedPath = './plugins/loom';
|
|
43
43
|
const existing = marketplace.plugins.find((entry) => typeof entry === 'object' && entry !== null && entry.name === 'loom');
|
|
44
|
-
if (existing && existing.source?.path && existing.source.path !== expectedPath) {
|
|
44
|
+
if (existing && existing.source?.path && existing.source.path !== expectedPath && !force) {
|
|
45
45
|
throw new InstallerError(`Codex marketplace already declares loom from a different path: ${existing.source.path}`, `refusing to take over non-Loom marketplace entry for loom: ${existing.source.path}`);
|
|
46
46
|
}
|
|
47
47
|
if (!existing) {
|
|
@@ -34,6 +34,12 @@ function prepareEnv(base) {
|
|
|
34
34
|
LOOM_INSTALLER_PYTHON_BIN: resolveTestPython(),
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
function writeCodexMarketplace(repoRoot, marketplace) {
|
|
38
|
+
const marketplacePath = join(repoRoot, '.agents', 'plugins', 'marketplace.json');
|
|
39
|
+
mkdirSync(join(repoRoot, '.agents', 'plugins'), { recursive: true });
|
|
40
|
+
writeFileSync(marketplacePath, JSON.stringify(marketplace, null, 2));
|
|
41
|
+
return marketplacePath;
|
|
42
|
+
}
|
|
37
43
|
function writeFakeClaude(binDir, logPath) {
|
|
38
44
|
const scriptPath = join(binDir, 'claude');
|
|
39
45
|
writeFileSync(scriptPath, `#!/bin/sh\nset -eu\nprintf '%s\\n' "$*" >> ${JSON.stringify(logPath)}\nif [ "$1" = "--version" ]; then\n echo '2.1.0'\n exit 0\nfi\nif [ "$1" = "plugin" ] && [ "$2" = "list" ] && [ "$3" = "--json" ]; then\n echo '[{"name":"loom"}]'\n exit 0\nfi\nexit 0\n`, { mode: 0o755 });
|
|
@@ -96,6 +102,67 @@ test('codex plugin install writes marketplace entry', () => {
|
|
|
96
102
|
assert.equal(marketplace.plugins[0].name, 'loom');
|
|
97
103
|
assert.equal(marketplace.plugins[0].source.path, './plugins/loom');
|
|
98
104
|
});
|
|
105
|
+
test('codex plugin install fails closed on marketplace conflicts without force', () => {
|
|
106
|
+
const base = fixtureRoot();
|
|
107
|
+
const envSource = prepareEnv(base);
|
|
108
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
109
|
+
const repoRoot = join(base, 'repo');
|
|
110
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
111
|
+
writeCodexMarketplace(repoRoot, {
|
|
112
|
+
name: 'custom-marketplace',
|
|
113
|
+
plugins: [
|
|
114
|
+
{
|
|
115
|
+
name: 'loom',
|
|
116
|
+
source: {
|
|
117
|
+
source: 'local',
|
|
118
|
+
path: './plugins/not-loom',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
const parsed = {
|
|
124
|
+
mode: 'plugin',
|
|
125
|
+
options: {
|
|
126
|
+
host: 'codex',
|
|
127
|
+
target: repoRoot,
|
|
128
|
+
force: false,
|
|
129
|
+
json: false,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
assert.throws(() => runInstaller(parsed, envSource, packageRoot()), /already declares loom from a different path/);
|
|
133
|
+
});
|
|
134
|
+
test('codex plugin install lets --force take over conflicting marketplace entry', () => {
|
|
135
|
+
const base = fixtureRoot();
|
|
136
|
+
const envSource = prepareEnv(base);
|
|
137
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
138
|
+
const repoRoot = join(base, 'repo');
|
|
139
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
140
|
+
writeCodexMarketplace(repoRoot, {
|
|
141
|
+
name: 'custom-marketplace',
|
|
142
|
+
plugins: [
|
|
143
|
+
{
|
|
144
|
+
name: 'loom',
|
|
145
|
+
source: {
|
|
146
|
+
source: 'local',
|
|
147
|
+
path: './plugins/not-loom',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
const parsed = {
|
|
153
|
+
mode: 'plugin',
|
|
154
|
+
options: {
|
|
155
|
+
host: 'codex',
|
|
156
|
+
target: repoRoot,
|
|
157
|
+
force: true,
|
|
158
|
+
json: false,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const result = runInstaller(parsed, envSource, packageRoot());
|
|
162
|
+
const marketplace = JSON.parse(readFileSync(join(repoRoot, '.agents', 'plugins', 'marketplace.json'), 'utf8'));
|
|
163
|
+
assert.equal(result.mode, 'plugin');
|
|
164
|
+
assert.equal(marketplace.plugins[0].source.path, './plugins/loom');
|
|
165
|
+
});
|
|
99
166
|
test('codex skill install writes skills.config entry', () => {
|
|
100
167
|
const base = fixtureRoot();
|
|
101
168
|
const envSource = prepareEnv(base);
|
|
@@ -120,6 +187,84 @@ test('codex skill install writes skills.config entry', () => {
|
|
|
120
187
|
assert.match(config, /loom-review\/SKILL\.md/);
|
|
121
188
|
assert.match(config, /enabled = true/);
|
|
122
189
|
});
|
|
190
|
+
test('codex skill install fails closed on conflicting skills.config entry without force', () => {
|
|
191
|
+
const base = fixtureRoot();
|
|
192
|
+
const envSource = prepareEnv(base);
|
|
193
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
194
|
+
const repoRoot = join(base, 'repo');
|
|
195
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
196
|
+
writeFileSync(join(envSource.CODEX_HOME, 'config.toml'), [
|
|
197
|
+
'model = "gpt-5"',
|
|
198
|
+
'',
|
|
199
|
+
'[[skills.config]]',
|
|
200
|
+
'path = "/tmp/other/loom-review/SKILL.md"',
|
|
201
|
+
'enabled = true',
|
|
202
|
+
'',
|
|
203
|
+
].join('\n'), 'utf8');
|
|
204
|
+
const parsed = {
|
|
205
|
+
mode: 'skill',
|
|
206
|
+
skillId: 'loom-review',
|
|
207
|
+
options: {
|
|
208
|
+
host: 'codex',
|
|
209
|
+
target: repoRoot,
|
|
210
|
+
force: false,
|
|
211
|
+
json: false,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
assert.throws(() => runInstaller(parsed, envSource, packageRoot()), /already has loom-review from a different path/);
|
|
215
|
+
});
|
|
216
|
+
test('codex skill install lets --force take over conflicting skills.config entry', () => {
|
|
217
|
+
const base = fixtureRoot();
|
|
218
|
+
const envSource = prepareEnv(base);
|
|
219
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
220
|
+
const repoRoot = join(base, 'repo');
|
|
221
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
222
|
+
writeFileSync(join(envSource.CODEX_HOME, 'config.toml'), [
|
|
223
|
+
'model = "gpt-5"',
|
|
224
|
+
'',
|
|
225
|
+
'[[skills.config]]',
|
|
226
|
+
'path = "/tmp/other/loom-review/SKILL.md"',
|
|
227
|
+
'enabled = true',
|
|
228
|
+
'',
|
|
229
|
+
].join('\n'), 'utf8');
|
|
230
|
+
const parsed = {
|
|
231
|
+
mode: 'skill',
|
|
232
|
+
skillId: 'loom-review',
|
|
233
|
+
options: {
|
|
234
|
+
host: 'codex',
|
|
235
|
+
target: repoRoot,
|
|
236
|
+
force: true,
|
|
237
|
+
json: false,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
const result = runInstaller(parsed, envSource, packageRoot());
|
|
241
|
+
const config = readFileSync(join(envSource.CODEX_HOME, 'config.toml'), 'utf8');
|
|
242
|
+
assert.equal(result.mode, 'skill');
|
|
243
|
+
assert.doesNotMatch(config, /\/tmp\/other\/loom-review\/SKILL\.md/);
|
|
244
|
+
assert.match(config, /loom-review\/SKILL\.md/);
|
|
245
|
+
});
|
|
246
|
+
test('single-skill installs stay scoped to the named skill for codex', () => {
|
|
247
|
+
const base = fixtureRoot();
|
|
248
|
+
const envSource = prepareEnv(base);
|
|
249
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
250
|
+
const repoRoot = join(base, 'repo');
|
|
251
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
252
|
+
const parsed = {
|
|
253
|
+
mode: 'skill',
|
|
254
|
+
skillId: 'loom-init',
|
|
255
|
+
options: {
|
|
256
|
+
host: 'codex',
|
|
257
|
+
target: repoRoot,
|
|
258
|
+
force: false,
|
|
259
|
+
json: false,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const result = runInstaller(parsed, envSource, packageRoot());
|
|
263
|
+
assert.equal(result.mode, 'skill');
|
|
264
|
+
assert.match(result.warnings[0] ?? '', /only the named skill, not the full Loom plugin surface/);
|
|
265
|
+
assert.equal(existsSync(join(repoRoot, 'plugins', 'loom')), false);
|
|
266
|
+
assert.equal(existsSync(join(repoRoot, '.agents', 'plugins', 'marketplace.json')), false);
|
|
267
|
+
});
|
|
123
268
|
test('claude plugin install assembles marketplace and calls claude CLI', () => {
|
|
124
269
|
const base = fixtureRoot();
|
|
125
270
|
const envSource = prepareEnv(base);
|
|
@@ -149,6 +294,28 @@ test('claude plugin install assembles marketplace and calls claude CLI', () => {
|
|
|
149
294
|
assert.match(log, /plugin marketplace add/);
|
|
150
295
|
assert.match(log, /plugin install loom@loom-local/);
|
|
151
296
|
});
|
|
297
|
+
test('single-skill installs stay scoped to the named skill for claude', () => {
|
|
298
|
+
const base = fixtureRoot();
|
|
299
|
+
const envSource = prepareEnv(base);
|
|
300
|
+
mkdirSync(envSource.CLAUDE_CONFIG_DIR, { recursive: true });
|
|
301
|
+
const repoRoot = join(base, 'repo');
|
|
302
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
303
|
+
const parsed = {
|
|
304
|
+
mode: 'skill',
|
|
305
|
+
skillId: 'loom-init',
|
|
306
|
+
options: {
|
|
307
|
+
host: 'claude',
|
|
308
|
+
target: repoRoot,
|
|
309
|
+
force: false,
|
|
310
|
+
json: false,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const result = runInstaller(parsed, envSource, packageRoot());
|
|
314
|
+
assert.equal(result.mode, 'skill');
|
|
315
|
+
assert.match(result.warnings[0] ?? '', /does not expose the full Loom plugin surface/);
|
|
316
|
+
assert.equal(existsSync(join(repoRoot, '.claude', 'skills', 'loom-init', 'SKILL.md')), true);
|
|
317
|
+
assert.equal(existsSync(join(repoRoot, '.claude', 'marketplaces', 'loom-local')), false);
|
|
318
|
+
});
|
|
152
319
|
test('cli emits structured json on success', () => {
|
|
153
320
|
const base = fixtureRoot();
|
|
154
321
|
const envSource = prepareEnv(base);
|
package/package.json
CHANGED
package/payload/manifest.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"schema_version": "loom-installer-payload/v1",
|
|
3
3
|
"loom_version": "0.4.0",
|
|
4
4
|
"source_repository": "https://github.com/MC-and-his-Agents/Loom",
|
|
5
|
-
"source_commit": "
|
|
5
|
+
"source_commit": "83a8df67892e8aa04b1ec02faac0acd7da4d1dbd",
|
|
6
6
|
"source_ref": "main",
|
|
7
|
-
"built_at": "2026-04-
|
|
7
|
+
"built_at": "2026-04-23T21:00:32+08:00",
|
|
8
8
|
"runtime": {
|
|
9
9
|
"python_minimum": "3.10",
|
|
10
10
|
"python_recommended": "3.11+"
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
"path": "plugin/loom/skills/distribution-and-adapter-contract.md",
|
|
75
|
-
"bytes":
|
|
76
|
-
"sha256": "
|
|
75
|
+
"bytes": 15198,
|
|
76
|
+
"sha256": "d6c46ae189d24328b62a52771347e7d7dc8ce0fddd4aee5c6ec096cbc19aec54"
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"path": "plugin/loom/skills/install-layout.json",
|
|
@@ -327,8 +327,8 @@
|
|
|
327
327
|
},
|
|
328
328
|
{
|
|
329
329
|
"path": "plugin/loom/skills/README.md",
|
|
330
|
-
"bytes":
|
|
331
|
-
"sha256": "
|
|
330
|
+
"bytes": 6723,
|
|
331
|
+
"sha256": "716f397324c9fa619d01f00c602929e93dcf68d20a93d2b65cfc4fec8ec52e43"
|
|
332
332
|
},
|
|
333
333
|
{
|
|
334
334
|
"path": "plugin/loom/skills/registry.json",
|
|
@@ -522,8 +522,8 @@
|
|
|
522
522
|
},
|
|
523
523
|
{
|
|
524
524
|
"path": "plugin/loom/skills/shared/scripts/loom_check.py",
|
|
525
|
-
"bytes":
|
|
526
|
-
"sha256": "
|
|
525
|
+
"bytes": 224303,
|
|
526
|
+
"sha256": "73bdf57078efceb8fac228a44c41fc9d92fc791fbe84699a837b92b491d50299"
|
|
527
527
|
},
|
|
528
528
|
{
|
|
529
529
|
"path": "plugin/loom/skills/shared/scripts/loom_flow.py",
|
|
@@ -33,12 +33,28 @@ Loom 在 `skills` 层固定承认两类对象:
|
|
|
33
33
|
|
|
34
34
|
## 安装入口
|
|
35
35
|
|
|
36
|
+
Loom 在安装层固定区分两条路径:
|
|
37
|
+
|
|
38
|
+
- 通过 npm 安装
|
|
39
|
+
- 面向“在别的项目里使用 Loom”
|
|
40
|
+
- 使用已发布的 `@mc-and-his-agents/loom-installer`
|
|
41
|
+
- 这是默认推荐路径
|
|
42
|
+
- 通过 Loom 仓库接入
|
|
43
|
+
- 面向“让 Agent 直接以 Loom 仓库作为接入来源”
|
|
44
|
+
- 适合调试、验证、演示,或希望按仓库当前 truth 直接接入的场景
|
|
45
|
+
- 这不是另一种 npm 命令,而是把 Loom 仓库地址交给 Agent 作为安装来源
|
|
46
|
+
|
|
47
|
+
如果目标是把 Loom 稳定接到别的项目里,优先走 npm 路径。
|
|
48
|
+
如果目标是验证仓库 truth、调试接入链,或让 Agent 直接按仓库当前状态完成接入,再走仓库路径。
|
|
49
|
+
|
|
50
|
+
### 通过 npm 安装
|
|
51
|
+
|
|
36
52
|
Loom 当前正式承认一条 npm / `npx` 安装入口:
|
|
37
53
|
|
|
38
54
|
- `npx @mc-and-his-agents/loom-installer add plugin`
|
|
39
55
|
- `npx @mc-and-his-agents/loom-installer add skill <skill-id>`
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
这条 npm 入口负责安装、发现与验证,不替代 Python runtime。
|
|
42
58
|
|
|
43
59
|
运行前提:
|
|
44
60
|
|
|
@@ -51,6 +67,22 @@ Loom 当前正式承认一条 npm / `npx` 安装入口:
|
|
|
51
67
|
- `add skill <skill-id>` 只承诺对应标准 skill
|
|
52
68
|
- 安装成功不等于已经执行 Loom runtime,只代表安装 / 发现 / 验证链成立
|
|
53
69
|
|
|
70
|
+
### 通过 Loom 仓库接入
|
|
71
|
+
|
|
72
|
+
如果不走已发布 npm 包,也可以把 Loom 仓库直接交给 Agent 作为接入来源:
|
|
73
|
+
|
|
74
|
+
- Loom 仓库:`https://github.com/MC-and-his-Agents/Loom`
|
|
75
|
+
|
|
76
|
+
这种方式表达的是“按仓库当前 truth 直接接入 Loom”,而不是“通过 npm 获取 installer”。
|
|
77
|
+
它适合:
|
|
78
|
+
|
|
79
|
+
- 验证仓库当前 truth 是否可被直接接入
|
|
80
|
+
- 调试、演示或本地试装 Loom
|
|
81
|
+
- 让 Agent 在当前环境里自行判断更适合 plugin 还是 single-skill 接入
|
|
82
|
+
|
|
83
|
+
这条路径不等于已经安装 `@mc-and-his-agents/loom-installer`。
|
|
84
|
+
如果用户需要稳定、可复用、面向已发布版本的安装面,应回到 npm 路径。
|
|
85
|
+
|
|
54
86
|
## 用户会看到哪些入口
|
|
55
87
|
|
|
56
88
|
Loom 当前稳定提供 1 个 root entry 和 7 个 scenario skills:
|
|
@@ -251,7 +251,8 @@ Node installer 的职责边界:
|
|
|
251
251
|
- 对安装失败维持 fail-closed
|
|
252
252
|
- main 分支是真相源
|
|
253
253
|
- PR 只做门禁,不直接发布 npm
|
|
254
|
-
-
|
|
254
|
+
- Loom 仓库主 release 与 installer npm 包版本线独立维护
|
|
255
|
+
- publish 成功后再创建 `loom-installer-v<version>` git tag 与同名前缀的 GitHub Release
|
|
255
256
|
|
|
256
257
|
Node installer 当前打包的正式安装源:
|
|
257
258
|
|
|
@@ -325,7 +326,7 @@ Node installer 的最小 verify 输出必须覆盖:
|
|
|
325
326
|
|
|
326
327
|
这类验证属于入口层能力验证,不等同于业务代码测试,也不等同于下游仓库的业务回归测试。
|
|
327
328
|
|
|
328
|
-
|
|
329
|
+
上述最小验证面当前已经形成 Loom 默认承认的稳定 core 接口;宿主完整回归矩阵仍停留在候选层,不进入 Loom 默认 core。
|
|
329
330
|
|
|
330
331
|
## 十、与 `automation-frontload` 的边界
|
|
331
332
|
|
|
@@ -334,8 +335,8 @@ Node installer 的最小 verify 输出必须覆盖:
|
|
|
334
335
|
- `automation-frontload` 的默认 core 检查
|
|
335
336
|
- 关注结构完整性、规则落点、模板存在性、交叉引用、执行支撑入口与明显越界信号
|
|
336
337
|
- `skills` 触发与行为回归验证
|
|
337
|
-
-
|
|
338
|
-
-
|
|
338
|
+
- 当前已形成 Loom 默认承认的最小入口回归面
|
|
339
|
+
- 只要求稳定合同与 fail-closed 语义,不要求每个仓库立即具备完整宿主矩阵
|
|
339
340
|
|
|
340
341
|
换言之,Loom 可以定义入口层最小验证面,但不要求每个仓库都立即具备完整宿主矩阵。
|
|
341
342
|
|
|
@@ -359,6 +359,10 @@ def load_json_file(path: Path) -> object:
|
|
|
359
359
|
return json.load(handle)
|
|
360
360
|
|
|
361
361
|
|
|
362
|
+
def load_text_file(path: Path) -> str:
|
|
363
|
+
return path.read_text(encoding="utf-8")
|
|
364
|
+
|
|
365
|
+
|
|
362
366
|
def run_command(
|
|
363
367
|
root: Path,
|
|
364
368
|
args: list[str],
|
|
@@ -1200,6 +1204,73 @@ def require_route_payload(
|
|
|
1200
1204
|
)
|
|
1201
1205
|
|
|
1202
1206
|
|
|
1207
|
+
def check_root_route_contracts(root: Path) -> list[Failure]:
|
|
1208
|
+
category = "skill-routing-contract"
|
|
1209
|
+
failures: list[Failure] = []
|
|
1210
|
+
readme_path = root / "README.md"
|
|
1211
|
+
skills_readme_path = root / "skills/README.md"
|
|
1212
|
+
route_matrix_path = root / "skills/route-matrix.md"
|
|
1213
|
+
contract_path = root / "skills/loom-init/contract.json"
|
|
1214
|
+
|
|
1215
|
+
try:
|
|
1216
|
+
readme = load_text_file(readme_path)
|
|
1217
|
+
skills_readme = load_text_file(skills_readme_path)
|
|
1218
|
+
route_matrix = load_text_file(route_matrix_path)
|
|
1219
|
+
contract = load_json_file(contract_path)
|
|
1220
|
+
except FileNotFoundError:
|
|
1221
|
+
return failures
|
|
1222
|
+
except json.JSONDecodeError as exc:
|
|
1223
|
+
return [Failure(category, f"`skills/loom-init/contract.json` is invalid JSON: {exc.msg}")]
|
|
1224
|
+
|
|
1225
|
+
if not isinstance(contract, dict):
|
|
1226
|
+
return [Failure(category, "`skills/loom-init/contract.json` must be a JSON object")]
|
|
1227
|
+
|
|
1228
|
+
if "### 完整接入 Loom Plugin" not in readme:
|
|
1229
|
+
failures.append(Failure(category, "`README.md` must keep the Loom plugin installation entry section"))
|
|
1230
|
+
if "### `loom-init`" not in readme:
|
|
1231
|
+
failures.append(Failure(category, "`README.md` must keep the single-skill `loom-init` installation section"))
|
|
1232
|
+
if "它是 Loom 唯一的 root entry" not in skills_readme:
|
|
1233
|
+
failures.append(Failure(category, "`skills/README.md` must keep `loom-init` as the unique root entry"))
|
|
1234
|
+
if "显式 skill 名称调用优先" not in route_matrix:
|
|
1235
|
+
failures.append(Failure(category, "`skills/route-matrix.md` must keep explicit routing as the first priority"))
|
|
1236
|
+
if "若无法稳定判断,回退到 `loom-init`" not in route_matrix:
|
|
1237
|
+
failures.append(Failure(category, "`skills/route-matrix.md` must keep fallback-to-loom-init semantics"))
|
|
1238
|
+
if "`plugin` 与 `single-skill` 两类安装结果边界" not in route_matrix and "fallback_to: \"loom-init\"" not in route_matrix:
|
|
1239
|
+
failures.append(Failure(category, "`skills/route-matrix.md` must keep the stable fallback payload contract"))
|
|
1240
|
+
|
|
1241
|
+
if contract.get("id") != "loom-init":
|
|
1242
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` id must remain `loom-init`"))
|
|
1243
|
+
if contract.get("root_entry") is not True:
|
|
1244
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` must keep `root_entry: true`"))
|
|
1245
|
+
|
|
1246
|
+
routing = contract.get("routing")
|
|
1247
|
+
if not isinstance(routing, dict):
|
|
1248
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` must declare `routing`"))
|
|
1249
|
+
else:
|
|
1250
|
+
if routing.get("reference") != "../route-matrix.md":
|
|
1251
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` must reference `../route-matrix.md`"))
|
|
1252
|
+
if routing.get("fallback_entry") != "loom-init":
|
|
1253
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` fallback entry must remain `loom-init`"))
|
|
1254
|
+
if routing.get("priority_order") != [
|
|
1255
|
+
"explicit skill name",
|
|
1256
|
+
"task signal routing",
|
|
1257
|
+
"fallback to loom-init with missing inputs",
|
|
1258
|
+
]:
|
|
1259
|
+
failures.append(Failure(category, "`skills/loom-init/contract.json` routing priority order drifted from the stable contract"))
|
|
1260
|
+
|
|
1261
|
+
installation_commands = (
|
|
1262
|
+
"npx @mc-and-his-agents/loom-installer add plugin",
|
|
1263
|
+
"npx @mc-and-his-agents/loom-installer add skill <skill-id>",
|
|
1264
|
+
)
|
|
1265
|
+
for command in installation_commands:
|
|
1266
|
+
if command not in skills_readme:
|
|
1267
|
+
failures.append(Failure(category, f"`skills/README.md` must document `{command}`"))
|
|
1268
|
+
if "请按当前 Agent 环境支持的方式,只接入 `loom-init`" not in readme:
|
|
1269
|
+
failures.append(Failure(category, "`README.md` must preserve the `loom-init` single-skill boundary prompt"))
|
|
1270
|
+
|
|
1271
|
+
return failures
|
|
1272
|
+
|
|
1273
|
+
|
|
1203
1274
|
def check_skill_manifests(root: Path) -> list[Failure]:
|
|
1204
1275
|
failures: list[Failure] = []
|
|
1205
1276
|
expected_entries = {
|
|
@@ -1484,22 +1555,18 @@ def check_skill_routing(root: Path) -> list[Failure]:
|
|
|
1484
1555
|
if error:
|
|
1485
1556
|
failures.append(Failure("skill-routing", f"explicit route for `{skill_id}` failed: {error}"))
|
|
1486
1557
|
continue
|
|
1487
|
-
|
|
1488
|
-
failures
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1558
|
+
require_route_payload(
|
|
1559
|
+
failures,
|
|
1560
|
+
category="skill-routing",
|
|
1561
|
+
context=f"explicit route for `{skill_id}`",
|
|
1562
|
+
payload=payload,
|
|
1563
|
+
expected_skill=skill_id,
|
|
1564
|
+
expected_mode="explicit",
|
|
1565
|
+
expected_runtime_scene="repo-local-demo",
|
|
1566
|
+
expected_runtime_carrier="repo-local-wrapper",
|
|
1567
|
+
)
|
|
1495
1568
|
if not isinstance(payload.get("summary"), str) or not payload.get("summary"):
|
|
1496
1569
|
failures.append(Failure("skill-routing", f"explicit route for `{skill_id}` must include `summary`"))
|
|
1497
|
-
if not isinstance(payload.get("matched_signals"), list):
|
|
1498
|
-
failures.append(Failure("skill-routing", f"explicit route for `{skill_id}` must include `matched_signals`"))
|
|
1499
|
-
if not isinstance(payload.get("missing_inputs"), list):
|
|
1500
|
-
failures.append(Failure("skill-routing", f"explicit route for `{skill_id}` must include `missing_inputs`"))
|
|
1501
|
-
if payload.get("fallback_to") != "loom-init":
|
|
1502
|
-
failures.append(Failure("skill-routing", f"explicit route for `{skill_id}` must keep `fallback_to: loom-init`"))
|
|
1503
1570
|
if skill_id in GOVERNANCE_SURFACE_ROUTE_SKILLS and payload.get("result") == "pass":
|
|
1504
1571
|
require_governance_surface(
|
|
1505
1572
|
failures,
|
|
@@ -1525,20 +1592,20 @@ def check_skill_routing(root: Path) -> list[Failure]:
|
|
|
1525
1592
|
if error:
|
|
1526
1593
|
failures.append(Failure("skill-routing", f"implicit route for `{skill_id}` failed: {error}"))
|
|
1527
1594
|
continue
|
|
1528
|
-
|
|
1529
|
-
failures
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1595
|
+
require_route_payload(
|
|
1596
|
+
failures,
|
|
1597
|
+
category="skill-routing",
|
|
1598
|
+
context=f"implicit route for `{skill_id}`",
|
|
1599
|
+
payload=payload,
|
|
1600
|
+
expected_skill=skill_id,
|
|
1601
|
+
expected_mode="implicit",
|
|
1602
|
+
expected_runtime_scene="repo-local-demo",
|
|
1603
|
+
expected_runtime_carrier="repo-local-wrapper",
|
|
1604
|
+
)
|
|
1536
1605
|
if not isinstance(payload.get("matched_signals"), list) or not payload.get("matched_signals"):
|
|
1537
1606
|
failures.append(Failure("skill-routing", f"implicit route for `{skill_id}` must include matched signals"))
|
|
1538
1607
|
if not isinstance(payload.get("summary"), str) or not payload.get("summary"):
|
|
1539
1608
|
failures.append(Failure("skill-routing", f"implicit route for `{skill_id}` must include `summary`"))
|
|
1540
|
-
if payload.get("fallback_to") != "loom-init":
|
|
1541
|
-
failures.append(Failure("skill-routing", f"implicit route for `{skill_id}` must keep `fallback_to: loom-init`"))
|
|
1542
1609
|
if skill_id in GOVERNANCE_SURFACE_ROUTE_SKILLS and payload.get("result") == "pass":
|
|
1543
1610
|
require_governance_surface(
|
|
1544
1611
|
failures,
|
|
@@ -1554,10 +1621,17 @@ def check_skill_routing(root: Path) -> list[Failure]:
|
|
|
1554
1621
|
if error:
|
|
1555
1622
|
failures.append(Failure("skill-routing", f"fallback route failed: {error}"))
|
|
1556
1623
|
else:
|
|
1557
|
-
|
|
1558
|
-
failures
|
|
1559
|
-
|
|
1560
|
-
|
|
1624
|
+
require_route_payload(
|
|
1625
|
+
failures,
|
|
1626
|
+
category="skill-routing",
|
|
1627
|
+
context="fallback route",
|
|
1628
|
+
payload=fallback_payload,
|
|
1629
|
+
expected_skill="loom-init",
|
|
1630
|
+
expected_mode="fallback",
|
|
1631
|
+
expected_runtime_scene="repo-local-demo",
|
|
1632
|
+
expected_runtime_carrier="repo-local-wrapper",
|
|
1633
|
+
allowed_results={"fallback"},
|
|
1634
|
+
)
|
|
1561
1635
|
if not isinstance(fallback_payload.get("missing_inputs"), list) or not fallback_payload.get("missing_inputs"):
|
|
1562
1636
|
failures.append(Failure("skill-routing", "fallback route must include `missing_inputs`"))
|
|
1563
1637
|
|
|
@@ -1576,10 +1650,17 @@ def check_skill_routing(root: Path) -> list[Failure]:
|
|
|
1576
1650
|
if error:
|
|
1577
1651
|
failures.append(Failure("skill-routing", f"ambiguous route failed: {error}"))
|
|
1578
1652
|
else:
|
|
1579
|
-
|
|
1580
|
-
failures
|
|
1581
|
-
|
|
1582
|
-
|
|
1653
|
+
require_route_payload(
|
|
1654
|
+
failures,
|
|
1655
|
+
category="skill-routing",
|
|
1656
|
+
context="multi-match route",
|
|
1657
|
+
payload=ambiguous_payload,
|
|
1658
|
+
expected_skill="loom-init",
|
|
1659
|
+
expected_mode="fallback",
|
|
1660
|
+
expected_runtime_scene="repo-local-demo",
|
|
1661
|
+
expected_runtime_carrier="repo-local-wrapper",
|
|
1662
|
+
allowed_results={"fallback"},
|
|
1663
|
+
)
|
|
1583
1664
|
if not isinstance(ambiguous_payload.get("matched_signals"), list) or len(ambiguous_payload.get("matched_signals", [])) < 2:
|
|
1584
1665
|
failures.append(Failure("skill-routing", "multi-match route must expose matched signals"))
|
|
1585
1666
|
|
|
@@ -1590,10 +1671,62 @@ def check_skill_routing(root: Path) -> list[Failure]:
|
|
|
1590
1671
|
if error:
|
|
1591
1672
|
failures.append(Failure("skill-routing", f"unknown explicit route failed: {error}"))
|
|
1592
1673
|
else:
|
|
1593
|
-
|
|
1594
|
-
failures
|
|
1595
|
-
|
|
1596
|
-
|
|
1674
|
+
require_route_payload(
|
|
1675
|
+
failures,
|
|
1676
|
+
category="skill-routing",
|
|
1677
|
+
context="unknown explicit route",
|
|
1678
|
+
payload=unknown_payload,
|
|
1679
|
+
expected_skill="loom-init",
|
|
1680
|
+
expected_mode="explicit",
|
|
1681
|
+
expected_runtime_scene="repo-local-demo",
|
|
1682
|
+
expected_runtime_carrier="repo-local-wrapper",
|
|
1683
|
+
allowed_results={"block"},
|
|
1684
|
+
)
|
|
1685
|
+
if "unknown skill" not in str(unknown_payload.get("summary", "")):
|
|
1686
|
+
failures.append(Failure("skill-routing", "unknown explicit skill must expose an `unknown skill` summary"))
|
|
1687
|
+
|
|
1688
|
+
with tempfile.TemporaryDirectory(prefix="loom-check-route-registry-") as tmp:
|
|
1689
|
+
broken_skills = Path(tmp) / "skills"
|
|
1690
|
+
shutil.copytree(root / "skills", broken_skills)
|
|
1691
|
+
registry_path = broken_skills / "registry.json"
|
|
1692
|
+
registry = load_json_file(registry_path)
|
|
1693
|
+
if isinstance(registry, dict):
|
|
1694
|
+
entries = registry.get("entries")
|
|
1695
|
+
if isinstance(entries, list):
|
|
1696
|
+
registry["entries"] = [
|
|
1697
|
+
entry
|
|
1698
|
+
for entry in entries
|
|
1699
|
+
if not (isinstance(entry, dict) and entry.get("id") == "loom-review")
|
|
1700
|
+
]
|
|
1701
|
+
registry_path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1702
|
+
drift_payload, error = load_command_json(
|
|
1703
|
+
root,
|
|
1704
|
+
[
|
|
1705
|
+
"python3",
|
|
1706
|
+
str(broken_skills / "loom-init" / "scripts" / "loom-init.py"),
|
|
1707
|
+
"route",
|
|
1708
|
+
"--target",
|
|
1709
|
+
str(target),
|
|
1710
|
+
"--task",
|
|
1711
|
+
"请对当前事项做正式 review 并给出审查结论",
|
|
1712
|
+
],
|
|
1713
|
+
)
|
|
1714
|
+
if error:
|
|
1715
|
+
failures.append(Failure("skill-routing", f"registry drift route failed: {error}"))
|
|
1716
|
+
else:
|
|
1717
|
+
require_route_payload(
|
|
1718
|
+
failures,
|
|
1719
|
+
category="skill-routing",
|
|
1720
|
+
context="registry drift route",
|
|
1721
|
+
payload=drift_payload,
|
|
1722
|
+
expected_skill="loom-init",
|
|
1723
|
+
expected_mode="implicit",
|
|
1724
|
+
expected_runtime_scene="installed-runtime",
|
|
1725
|
+
expected_runtime_carrier="installed-skills-root",
|
|
1726
|
+
allowed_results={"block"},
|
|
1727
|
+
)
|
|
1728
|
+
if "route table resolved to unknown registry skill" not in str(drift_payload.get("summary", "")):
|
|
1729
|
+
failures.append(Failure("skill-routing", "registry drift route must expose an unknown registry skill summary"))
|
|
1597
1730
|
|
|
1598
1731
|
return failures
|
|
1599
1732
|
|
|
@@ -4677,6 +4810,7 @@ def collect_failures(root: Path) -> list[Failure]:
|
|
|
4677
4810
|
AUTOMATION_FRONTLOAD_EXECUTION_SUPPORT,
|
|
4678
4811
|
)
|
|
4679
4812
|
)
|
|
4813
|
+
failures.extend(check_root_route_contracts(root))
|
|
4680
4814
|
failures.extend(check_skill_manifests(root))
|
|
4681
4815
|
failures.extend(check_skill_routing(root))
|
|
4682
4816
|
failures.extend(check_demo_assets(root))
|
|
@@ -4692,7 +4826,7 @@ def collect_failures(root: Path) -> list[Failure]:
|
|
|
4692
4826
|
|
|
4693
4827
|
|
|
4694
4828
|
def print_report(root: Path, failures: list[Failure]) -> None:
|
|
4695
|
-
categories_checked =
|
|
4829
|
+
categories_checked = 17
|
|
4696
4830
|
if not failures:
|
|
4697
4831
|
print(f"loom_check: OK ({root})")
|
|
4698
4832
|
print(f"checked {categories_checked} surfaces")
|