skill-family-engineering-kit 0.1.3 → 0.2.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.
@@ -840,9 +840,9 @@ pnpm check
840
840
  </code></pre>
841
841
  <ul>
842
842
  <li><code>pnpm synth</code> 再生成 projen 受管文件。受管文件只能通过修改 <code>.projenrc.js</code> 间接变更;手写源码、文档和 fixture 不会被 synth 覆盖。</li>
843
- <li><code>pnpm check</code> 是当前统一根门禁:10 个稳定门禁 ID 按固定顺序串联执行,任一步骤非 0 即整体失败;本仓没有任何 CI/CD workflow,全部门禁都在私有工作区内运行。下方区块由根 <code>package.json</code> 的 <code>scripts.check</code> 机械投影(<code>node scripts/docs/fact-check.mjs</code> 逐字核对,禁止手写清单再次滞后):</li>
843
+ <li><code>pnpm check</code> 是当前统一根门禁:11 个稳定门禁 ID 按固定顺序串联执行,任一步骤非 0 即整体失败;本仓没有任何 CI/CD workflow,全部门禁都在私有工作区内运行。下方区块由根 <code>package.json</code> 的 <code>scripts.check</code> 机械投影(<code>node scripts/docs/fact-check.mjs</code> 逐字核对,禁止手写清单再次滞后):</li>
844
844
  </ul>
845
- <!-- BEGIN SKILL-FAMILY-GATE-STAGES v2 (10 stable gate IDs; derived from package.json scripts.check; verified by fact-check) -->
845
+ <!-- BEGIN SKILL-FAMILY-GATE-STAGES v2 (11 stable gate IDs; derived from package.json scripts.check; verified by fact-check) -->
846
846
  <ol>
847
847
  <li>pnpm run check:structure</li>
848
848
  <li>pnpm run check:packages</li>
@@ -854,6 +854,7 @@ pnpm check
854
854
  <li>pnpm run check:release-artifacts</li>
855
855
  <li>pnpm run check:projen</li>
856
856
  <li>pnpm run check:integration</li>
857
+ <li>pnpm run check:artifacts</li>
857
858
  </ol>
858
859
  <!-- END SKILL-FAMILY-GATE-STAGES -->
859
860
 
package/package.json CHANGED
@@ -8,8 +8,8 @@
8
8
  "url": "https://github.com/ifoohoo/skill-family-engineering-kit/issues"
9
9
  },
10
10
  "dependencies": {
11
- "skill-family-contracts": "0.1.3",
12
- "skill-family-harness-node": "0.1.3"
11
+ "skill-family-contracts": "0.2.0",
12
+ "skill-family-harness-node": "0.2.0"
13
13
  },
14
14
  "description": "Build-time scaffold, adoption planning, projection, and checks.",
15
15
  "engines": {
@@ -35,7 +35,7 @@
35
35
  "url": "https://github.com/ifoohoo/skill-family-engineering-kit.git"
36
36
  },
37
37
  "type": "module",
38
- "version": "0.1.3",
38
+ "version": "0.2.0",
39
39
  "scripts": {
40
40
  "check": "node --test",
41
41
  "test": "node --test"
package/src/cli.mjs CHANGED
@@ -2,7 +2,9 @@
2
2
  import { realpathSync } from "node:fs";
3
3
  import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { readFileContained, resolveContained } from "skill-family-harness-node";
5
6
  import { bundledProfilesRoot } from "./licensing.mjs";
7
+ import { checkReportAction, renderReportAction } from "./report.mjs";
6
8
  import {
7
9
  COMMAND_SIDE_EFFECTS,
8
10
  KIT_EXIT_CODES,
@@ -12,14 +14,23 @@ import {
12
14
  unknownCommandError,
13
15
  invalidParamsError,
14
16
  mutationModeError,
17
+ describeHost,
18
+ probeHost,
19
+ buildHostAdapter,
20
+ materializeHostBuild,
21
+ planHost,
22
+ refuseHostApply,
15
23
  } from "./index.mjs";
16
24
 
17
25
  /**
18
26
  * skill-family-engineering-kit CLI.
19
27
  *
20
- * Exactly four top-level commands exist; there is no fifth. Exit codes are
21
- * stable: 0 = ok/clean, 1 = findings (check), 2 = rejected/usage/mechanism
22
- * error. Every refusal prints its registered SFC code and stable kind.
28
+ * Exactly four top-level commands exist; there is no fifth. The report layer
29
+ * (FND-ADR-005) hangs under two of them as positional sub-actions —
30
+ * `projection report` (render) and `check report` (graded diagnosis) — not as
31
+ * a fifth command. Exit codes are stable: 0 = ok/clean, 1 = findings (check),
32
+ * 2 = rejected/usage/mechanism error. Every refusal prints its registered
33
+ * SFC code and stable kind.
23
34
  */
24
35
 
25
36
  const HELP_TEXT = `skill-family-engineering-kit —— 构建期工程工具包(恰好四个顶层命令)
@@ -37,15 +48,22 @@ const HELP_TEXT = `skill-family-engineering-kit —— 构建期工程工具包
37
48
  projection 投影受管生成物。
38
49
  副作用: 只写 manifest 授权且被目标声明为受管的路径;
39
50
  未授权路径、手写文件与越界路径一律拒绝(拒绝时零写入)。
51
+ 报告子动作: projection report 将调用方提交的 report-model 确定性渲染为
52
+ 中立 Markdown(FND-ADR-005);默认只写 stdout,给定 --out/
53
+ --binding 时只写这两个显式且被 --root 收容的路径(原子写)。
40
54
  check 契约/漂移/闭包/版本/文档事实/Git 前置状态诊断。
41
55
  副作用: 无 —— 只诊断、绝不自动修复,git 仅只读探测。
56
+ 报告子动作: check report 对一份已渲染报告做分级诊断:
57
+ 硬失败(SFC3001/3002/3003)计为发现(退出码 1);
58
+ 风格告警只报告、绝不阻塞机器正确的报告。
42
59
 
43
60
  全局选项:
44
61
  --root <dir> 目标工作区根目录(默认当前目录)
45
62
  --help, -h 显示本帮助或单个命令的帮助
46
63
 
47
64
  退出码: 0 成功/无发现;1 check 有发现;2 拒绝/用法/机制错误。
48
- 错误码复用 contracts 的 SFC* 体系(SFC2002/SFC2003/SFC2004 + 稳定 details.kind)。
65
+ 错误码复用 contracts 的 SFC* 体系(SFC2002/SFC2003/SFC2004 + 稳定 details.kind;
66
+ 报告层硬失败使用 SFC3001/SFC3002/SFC3003)。
49
67
  禁止项: git init/commit/push/tag、发布、删除、远端写入;不存在第五个顶层命令。
50
68
  `;
51
69
 
@@ -61,6 +79,10 @@ function commandHelp(command) {
61
79
  " --licensing-profile <id> 许可证 Profile id(默认 registry 第一个变体)",
62
80
  " --licensing-variant <id> 多变体 Profile 的必选变体 id",
63
81
  " --profiles-root <dir> 许可证 Profile 根目录",
82
+ "",
83
+ "宿主构建: skill-family-kit scaffold host-build --root <workspace> --host <id>",
84
+ " --path-category <id> --input <relpath> --out <relpath> [--hosts-root <dir>]",
85
+ " 将完整 source closure 逐字节写入 sibling staging,验证后一次 rename;目标必须不存在。",
64
86
  ],
65
87
  "adopt-plan": [
66
88
  "选项:",
@@ -72,6 +94,11 @@ function commandHelp(command) {
72
94
  " --profiles-root <dir> 许可证 Profile 根目录",
73
95
  " --no-git-spawn 禁用只读 git status 探测,仅用文件系统事实",
74
96
  "",
97
+ "只读宿主子动作: host-describe | host-probe | host-plan | host-apply",
98
+ " host-probe 默认不执行进程;仅 --allow-host-spawn + --host-executable <绝对路径> 可启用受审计版本向量;",
99
+ " host-plan 必须显式提供 --build-manifest/--probe-facts,绝不隐式探测;",
100
+ " host-apply 在本阶段稳定拒绝,且不会产生占位文件或临时目录。",
101
+ "",
75
102
  " 迁移闭环(FND-070): 只读读取目标仓 skill-family.migration.json",
76
103
  " (旧实现退出清单 + 临时例外);例外缺 owner/reason/deadline/",
77
104
  " migrationTarget 任一字段即计为冲突;到期例外不自动续期;",
@@ -81,6 +108,13 @@ function commandHelp(command) {
81
108
  "选项:",
82
109
  " --root <dir> 目标工作区(默认当前目录)",
83
110
  " --manifest <relpath> 投影 manifest 相对路径(默认 skill-family.projection.json)",
111
+ "",
112
+ "报告子动作: skill-family-kit projection report [options]",
113
+ " --model <relpath> 经 Contracts 验证的 report-model JSON(必填)",
114
+ " --result <relpath> 机器结果 JSON(必填,仅用于摘要和错误绑定核对)",
115
+ " --out <relpath> 报告 Markdown 输出路径;缺省时 Markdown 只写 stdout",
116
+ " --binding <relpath> 同时写出 report-binding JSON(model/result/report 三摘要)",
117
+ " 确定性: 同一 report-model 反复渲染字节一致;Harness 不推导状态、不补造事实。",
84
118
  ],
85
119
  check: [
86
120
  "选项:",
@@ -88,6 +122,14 @@ function commandHelp(command) {
88
122
  " --only <class> 只运行一个诊断类: contracts|drift|closure|version|docs|git|identity",
89
123
  " --profiles-root <dir> 许可证 Profile 根目录",
90
124
  " --no-git-spawn 禁用只读 git status 探测,仅用文件系统事实",
125
+ "",
126
+ "报告子动作: skill-family-kit check report [options]",
127
+ " --report <relpath> 已渲染报告 Markdown(必填,只读)",
128
+ " --model <relpath> 对应 report-model JSON(必填,只读)",
129
+ " --result <relpath> 对应机器结果 JSON(必填,只读;核对 digest/errors)",
130
+ " --binding <relpath> 可选 report-binding JSON;摘要过期即 SFC3001",
131
+ " 分级: 硬失败(SFC3001/3002/3003)计为发现,退出码 1;",
132
+ " 风格告警(超长句/重复段/翻译腔/未解释术语)只报告、绝不改变判定。",
91
133
  ],
92
134
  };
93
135
  return [
@@ -172,8 +214,134 @@ const COMMAND_SPECS = {
172
214
  "--no-git-spawn": { key: "noGitSpawn", value: false },
173
215
  },
174
216
  },
217
+ "projection report": {
218
+ flags: {
219
+ "--root": { key: "root", value: true },
220
+ "--model": { key: "model", value: true },
221
+ "--result": { key: "result", value: true },
222
+ "--out": { key: "out", value: true },
223
+ "--binding": { key: "binding", value: true },
224
+ },
225
+ },
226
+ "check report": {
227
+ flags: {
228
+ "--root": { key: "root", value: true },
229
+ "--report": { key: "report", value: true },
230
+ "--model": { key: "model", value: true },
231
+ "--result": { key: "result", value: true },
232
+ "--binding": { key: "binding", value: true },
233
+ },
234
+ },
235
+ "scaffold host-build": {
236
+ flags: {
237
+ "--root": { key: "root", value: true },
238
+ "--host": { key: "hostId", value: true },
239
+ "--path-category": { key: "pathCategoryId", value: true },
240
+ "--input": { key: "input", value: true },
241
+ "--out": { key: "out", value: true },
242
+ "--hosts-root": { key: "hostsRoot", value: true },
243
+ },
244
+ },
245
+ "adopt-plan host-describe": {
246
+ flags: { "--host": { key: "hostId", value: true }, "--hosts-root": { key: "hostsRoot", value: true } },
247
+ },
248
+ "adopt-plan host-probe": {
249
+ flags: {
250
+ "--host": { key: "hostId", value: true },
251
+ "--hosts-root": { key: "hostsRoot", value: true },
252
+ "--allow-host-spawn": { key: "allowHostSpawn", value: false },
253
+ "--host-executable": { key: "hostExecutable", value: true },
254
+ },
255
+ },
256
+ "adopt-plan host-plan": {
257
+ flags: {
258
+ "--root": { key: "root", value: true },
259
+ "--host": { key: "hostId", value: true },
260
+ "--path-category": { key: "pathCategoryId", value: true },
261
+ "--build-manifest": { key: "buildManifest", value: true },
262
+ "--probe-facts": { key: "probeFacts", value: true },
263
+ "--hosts-root": { key: "hostsRoot", value: true },
264
+ },
265
+ },
266
+ "adopt-plan host-apply": { flags: {} },
175
267
  };
176
268
 
269
+ async function readJson(root, relPath, label) {
270
+ if (!relPath) throw invalidParamsError(`${label} is required`);
271
+ try {
272
+ return JSON.parse((await readFileContained(root, relPath)).toString("utf8"));
273
+ } catch (cause) {
274
+ if (cause?.code?.startsWith?.("SFC")) throw cause;
275
+ throw invalidParamsError(`${label} must name valid JSON contained in --root`);
276
+ }
277
+ }
278
+
279
+ async function runHostSubAction(command, action, rest) {
280
+ const specName = `${command} ${action}`;
281
+ try {
282
+ if (action === "host-apply") refuseHostApply();
283
+ const spec = COMMAND_SPECS[specName];
284
+ const options = parseOptions(rest, { command: specName, flags: spec.flags });
285
+ const hostsRoot = options.hostsRoot;
286
+ let output;
287
+ if (action === "host-describe") {
288
+ output = await describeHost({ hostId: options.hostId, hostsRoot });
289
+ } else if (action === "host-probe") {
290
+ output = await probeHost({ hostId: options.hostId, hostsRoot, allowSpawn: options.allowHostSpawn === true, executable: options.hostExecutable });
291
+ } else if (action === "host-build") {
292
+ const root = options.root ?? ".";
293
+ const input = await readJson(root, options.input, "--input");
294
+ const build = await buildHostAdapter({ hostId: options.hostId, pathCategoryId: options.pathCategoryId, input, hostsRoot });
295
+ if (build.status === "built") {
296
+ if (!options.out) throw invalidParamsError("--out is required");
297
+ const targetRoot = await resolveContained(root, options.out);
298
+ await materializeHostBuild({ targetRoot, build });
299
+ }
300
+ output = build.status === "built" ? { status: "built", manifest: build.manifest, outputRoot: options.out } : build;
301
+ } else if (action === "host-plan") {
302
+ const root = options.root ?? ".";
303
+ const buildManifest = await readJson(root, options.buildManifest, "--build-manifest");
304
+ const probeDocument = await readJson(root, options.probeFacts, "--probe-facts");
305
+ const probeFacts = Array.isArray(probeDocument) ? probeDocument : probeDocument.facts;
306
+ output = await planHost({ hostId: options.hostId, pathCategoryId: options.pathCategoryId, buildManifest, probeFacts, hostsRoot });
307
+ }
308
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
309
+ return KIT_EXIT_CODES.ok;
310
+ } catch (error) {
311
+ printError(error);
312
+ return KIT_EXIT_CODES.rejected;
313
+ }
314
+ }
315
+
316
+ async function runReportSubAction(command, rest) {
317
+ const specName = `${command} report`;
318
+ try {
319
+ const options = parseOptions(rest, { command: specName, flags: COMMAND_SPECS[specName].flags });
320
+ if (options.help) {
321
+ process.stdout.write(`${commandHelp(command)}\n`);
322
+ return KIT_EXIT_CODES.ok;
323
+ }
324
+ const action = command === "projection" ? renderReportAction : checkReportAction;
325
+ const { status, output } = await action({
326
+ root: options.root,
327
+ model: options.model,
328
+ result: options.result,
329
+ report: options.report,
330
+ out: options.out,
331
+ binding: options.binding,
332
+ });
333
+ if (output !== undefined) {
334
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
335
+ }
336
+ if (status === "ok") return KIT_EXIT_CODES.ok;
337
+ if (status === "findings") return KIT_EXIT_CODES.findings;
338
+ return KIT_EXIT_CODES.rejected;
339
+ } catch (error) {
340
+ printError(error);
341
+ return KIT_EXIT_CODES.rejected;
342
+ }
343
+ }
344
+
177
345
  export async function cliMain(argv) {
178
346
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
179
347
  process.stdout.write(HELP_TEXT);
@@ -185,6 +353,19 @@ export async function cliMain(argv) {
185
353
  return KIT_EXIT_CODES.rejected;
186
354
  }
187
355
 
356
+ // Positional report sub-actions (FND-ADR-005): `projection report` renders,
357
+ // `check report` diagnoses. They hang under existing commands; the
358
+ // four-command vocabulary and runCommand stay untouched.
359
+ if ((command === "projection" || command === "check") && argv[1] === "report") {
360
+ return runReportSubAction(command, argv.slice(2));
361
+ }
362
+ if (command === "scaffold" && argv[1] === "host-build") {
363
+ return runHostSubAction(command, "host-build", argv.slice(2));
364
+ }
365
+ if (command === "adopt-plan" && ["host-describe", "host-probe", "host-plan", "host-apply"].includes(argv[1])) {
366
+ return runHostSubAction(command, argv[1], argv.slice(2));
367
+ }
368
+
188
369
  try {
189
370
  const options = parseOptions(argv.slice(1), { command, flags: COMMAND_SPECS[command].flags });
190
371
  if (options.help) {
package/src/errors.mjs CHANGED
@@ -67,6 +67,14 @@ export const KIT_ERROR_KINDS = Object.freeze({
67
67
  SYMLINK_ENTRY: "symlink-entry",
68
68
  SPECIAL_ENTRY: "special-entry",
69
69
  UNCONTAINED_DECLARATION: "uncontained-declaration",
70
+ // report sub-actions (projection report / check report, FND-ADR-005)
71
+ REPORT_INPUT_MISSING: "report-input-missing",
72
+ REPORT_PATH_CONFLICT: "report-path-conflict",
73
+ REPORT_WRITE_FAILED: "report-write-failed",
74
+ // read-only host integration slice
75
+ HOST_CONTRACT_INVALID: "host-contract-invalid",
76
+ HOST_PROBE_FAILED: "host-probe-failed",
77
+ HOST_BUILD_FAILED: "host-build-failed",
70
78
  });
71
79
 
72
80
  /** Error carrying one frozen SFC code plus a stable kit kind in details. */
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+ import { probeVersionVector } from "skill-family-harness-node";
3
+
4
+ const CAPABILITIES = Object.freeze(["cli", "version", "payload", "registry", "discover", "enabled", "reload", "smoke", "uninstall"]);
5
+ const DRIVERS = Object.freeze({
6
+ "claude-version-v1": Object.freeze({ executableBasename: "claude", argv: Object.freeze(["--version"]) }),
7
+ "codex-version-v1": Object.freeze({ executableBasename: "codex", argv: Object.freeze(["--version"]) }),
8
+ });
9
+
10
+ /**
11
+ * Selects one audited host-specific driver vector. The generic process and
12
+ * four-state classification mechanism lives in Harness.
13
+ */
14
+ export async function probeTrustedVersionDriver({ hostId, driverId, capabilities = CAPABILITIES, executable, allowSpawn = false, timeoutMs = 5000, runner } = {}) {
15
+ const driver = DRIVERS[driverId];
16
+ if (!driver) throw new TypeError(`unknown frozen host driver: ${String(driverId)}`);
17
+ if (allowSpawn && path.basename(executable ?? "") !== driver.executableBasename) {
18
+ throw new TypeError(`driver ${driverId} requires an explicit ${driver.executableBasename} executable`);
19
+ }
20
+ return probeVersionVector({ hostId, capabilities, executable, argv: driver.argv, allowSpawn, timeoutMs, runner });
21
+ }
22
+
23
+ export const HOST_CAPABILITIES = CAPABILITIES;
24
+ export const HOST_DRIVER_IDS = Object.freeze(Object.keys(DRIVERS));
@@ -0,0 +1,51 @@
1
+ import { validateDocument } from "skill-family-contracts";
2
+ import { readFileContained } from "skill-family-harness-node";
3
+ import { invalidParamsError, kitError, KIT_ERROR_KINDS } from "./errors.mjs";
4
+
5
+ const HOST_DESCRIPTOR_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-descriptor.json";
6
+ const HOST_REGISTRY_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-registry.json";
7
+ const HOST_ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
8
+
9
+ function validateRegistered(document, schemaId, message) {
10
+ const result = validateDocument(document, { schemaId, dialect: "2020-12", policy: "strict" });
11
+ if (!result.valid) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, message, { errors: result.errors });
12
+ return result.data;
13
+ }
14
+
15
+ function assertRegistrySemantics(registry) {
16
+ return registry;
17
+ }
18
+
19
+ export async function loadHostRegistry({ hostsRoot, registry } = {}) {
20
+ if (registry !== undefined) return Object.freeze(structuredClone(assertRegistrySemantics(validateRegistered(registry, HOST_REGISTRY_SCHEMA_ID, "host registry fails its registered contract"))));
21
+ if (!hostsRoot) throw invalidParamsError("loadHostRegistry requires an explicit hostsRoot or injected registry");
22
+ let document;
23
+ try {
24
+ document = JSON.parse((await readFileContained(hostsRoot, "registry.json")).toString("utf8"));
25
+ } catch (cause) {
26
+ if (cause?.code?.startsWith?.("SFC")) throw cause;
27
+ throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host registry cannot be read or parsed", { causeKind: cause?.details?.kind ?? "unknown" });
28
+ }
29
+ return Object.freeze(structuredClone(assertRegistrySemantics(validateRegistered(document, HOST_REGISTRY_SCHEMA_ID, "host registry fails its registered contract"))));
30
+ }
31
+
32
+ export async function describeHost({ hostId, hostsRoot, registry } = {}) {
33
+ if (!HOST_ID_PATTERN.test(hostId ?? "")) throw invalidParamsError("describeHost requires a valid hostId");
34
+ const loaded = await loadHostRegistry({ hostsRoot, registry });
35
+ if (!loaded.hosts.includes(hostId)) throw invalidParamsError(`unknown host: ${hostId}`, { hostId });
36
+ if (!hostsRoot) throw invalidParamsError("host descriptor resolution requires an explicit hostsRoot");
37
+ let document;
38
+ try {
39
+ document = JSON.parse((await readFileContained(hostsRoot, `${hostId}/host-descriptor.json`)).toString("utf8"));
40
+ } catch (cause) {
41
+ if (cause?.code?.startsWith?.("SFC")) throw cause;
42
+ throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor cannot be read or parsed", { hostId, causeKind: cause?.details?.kind ?? "unknown" });
43
+ }
44
+ const descriptor = validateRegistered(document, HOST_DESCRIPTOR_SCHEMA_ID, "host descriptor fails its registered contract");
45
+ if (descriptor.hostId !== hostId) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor hostId does not match its registry entry", { hostId });
46
+ const categoryIds = descriptor.pathCategories.map((category) => category.id);
47
+ if (new Set(categoryIds).size !== categoryIds.length) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor has duplicate path category ids", { hostId });
48
+ return Object.freeze(structuredClone(descriptor));
49
+ }
50
+
51
+ export { HOST_DESCRIPTOR_SCHEMA_ID, HOST_REGISTRY_SCHEMA_ID };
package/src/host.mjs ADDED
@@ -0,0 +1,108 @@
1
+ import path from "node:path";
2
+ import { canonicalJson, digestDocument, validateDocument } from "skill-family-contracts";
3
+ import {
4
+ buildAdapterClosure,
5
+ materializeAdapterBuild,
6
+ verifyAdapterBuildManifest,
7
+ } from "skill-family-harness-node";
8
+ import { invalidParamsError, kitError, KIT_ERROR_KINDS } from "./errors.mjs";
9
+ import { HOST_CAPABILITIES, probeTrustedVersionDriver } from "./host-drivers.mjs";
10
+ import { describeHost } from "./host-profiles.mjs";
11
+
12
+ const FACT_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-capability-fact.json";
13
+ const PROBE_RESULT_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-probe-result.json";
14
+ const PLAN_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-operation-plan.json";
15
+
16
+ function validateContract(document, schemaId, message) {
17
+ const result = validateDocument(document, { schemaId, dialect: "2020-12", policy: "strict" });
18
+ if (!result.valid) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, message, { errors: result.errors });
19
+ return result.data;
20
+ }
21
+
22
+ function selectCategory(descriptor, pathCategoryId) {
23
+ const category = descriptor.pathCategories.find((candidate) => candidate.id === pathCategoryId);
24
+ if (!category) throw invalidParamsError(`host ${descriptor.hostId} does not declare path category ${String(pathCategoryId)}`);
25
+ return { id: category.id, scope: category.scope, anchor: category.anchor, relPath: category.relPath };
26
+ }
27
+
28
+ export async function probeHost({ hostId, hostsRoot, registry, executable, allowSpawn = false, timeoutMs = 5000, runner } = {}) {
29
+ const descriptor = await describeHost({ hostId, hostsRoot, registry });
30
+ if (descriptor.support === "unsupported") {
31
+ return validateContract({ schemaVersion: 1, kind: "skill-family.host-probe-result", hostId, support: "unsupported", reason: descriptor.unsupportedReason, facts: [] }, PROBE_RESULT_SCHEMA_ID, "unsupported probe result fails its contract");
32
+ }
33
+ const facts = await probeTrustedVersionDriver({
34
+ hostId,
35
+ driverId: descriptor.driverId,
36
+ capabilities: descriptor.probeCapabilities,
37
+ executable,
38
+ allowSpawn,
39
+ timeoutMs,
40
+ runner,
41
+ });
42
+ for (const fact of facts) validateContract(fact, FACT_SCHEMA_ID, "host driver emitted an invalid capability fact");
43
+ const names = facts.map((fact) => fact.capability);
44
+ if (new Set(names).size !== HOST_CAPABILITIES.length || !HOST_CAPABILITIES.every((name) => names.includes(name))) {
45
+ throw kitError(KIT_ERROR_KINDS.HOST_PROBE_FAILED, "host driver did not emit the exact capability fact set");
46
+ }
47
+ return validateContract({ schemaVersion: 1, kind: "skill-family.host-probe-result", hostId, support: "supported", facts }, PROBE_RESULT_SCHEMA_ID, "host probe result fails its contract");
48
+ }
49
+
50
+ export async function buildHostAdapter({ hostId, pathCategoryId, input, hostsRoot, registry } = {}) {
51
+ const descriptor = await describeHost({ hostId, hostsRoot, registry });
52
+ if (descriptor.support !== "supported") return { status: "unsupported", reason: descriptor.unsupportedReason };
53
+ return buildAdapterClosure({ hostId, pathCategory: selectCategory(descriptor, pathCategoryId), input });
54
+ }
55
+
56
+ export async function materializeHostBuild(options) {
57
+ return materializeAdapterBuild(options);
58
+ }
59
+
60
+ function validateFactSet(hostId, facts) {
61
+ if (!Array.isArray(facts) || facts.length !== HOST_CAPABILITIES.length) throw invalidParamsError(`planHost requires exactly ${HOST_CAPABILITIES.length} explicit probe facts`);
62
+ for (const fact of facts) {
63
+ validateContract(fact, FACT_SCHEMA_ID, "planHost received an invalid probe fact");
64
+ if (fact.hostId !== hostId) throw invalidParamsError("probe fact hostId does not match the planned host");
65
+ }
66
+ const names = facts.map((fact) => fact.capability);
67
+ if (new Set(names).size !== names.length || !HOST_CAPABILITIES.every((name) => names.includes(name))) throw invalidParamsError("probe facts must cover each capability exactly once");
68
+ return [...facts].sort((left, right) => HOST_CAPABILITIES.indexOf(left.capability) - HOST_CAPABILITIES.indexOf(right.capability));
69
+ }
70
+
71
+ export async function planHost({ hostId, pathCategoryId, buildManifest, probeFacts, hostsRoot, registry } = {}) {
72
+ const descriptor = await describeHost({ hostId, hostsRoot, registry });
73
+ if (descriptor.support !== "supported") {
74
+ const base = { schemaVersion: 1, kind: "skill-family.host-operation-plan", hostId, status: "unsupported", unsupportedReason: descriptor.unsupportedReason, probeFacts: [], actions: [], writeSet: [] };
75
+ return validateContract({ ...base, digest: digestDocument(base) }, PLAN_SCHEMA_ID, "unsupported host plan fails its contract");
76
+ }
77
+ const category = selectCategory(descriptor, pathCategoryId);
78
+ const facts = validateFactSet(hostId, probeFacts);
79
+ const manifest = verifyAdapterBuildManifest(buildManifest, { hostId, pathCategory: category });
80
+ const actions = manifest.members.map((member, index) => ({ sequence: index + 1, kind: "install-file", categoryId: category.id, scope: category.scope, anchor: category.anchor, target: member.target, expect: "absent", sourceSha256: member.sha256 }));
81
+ const writeSet = actions.map(({ categoryId, scope, anchor, target, sourceSha256 }) => ({ categoryId, scope, anchor, target, sourceSha256 }));
82
+ const base = { schemaVersion: 1, kind: "skill-family.host-operation-plan", hostId, status: "planned", pathCategory: category, probeFacts: facts, actions, writeSet };
83
+ const plan = validateContract({ ...base, digest: digestDocument(base) }, PLAN_SCHEMA_ID, "host operation plan fails its contract");
84
+ assertPlanConsistency(plan);
85
+ return plan;
86
+ }
87
+
88
+ export function assertPlanConsistency(planInput) {
89
+ const plan = validateContract(planInput, PLAN_SCHEMA_ID, "host operation plan fails its registered contract");
90
+ const { digest, ...base } = plan;
91
+ if (digestDocument(base) !== digest) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan digest does not match its content");
92
+ if (plan.status === "unsupported") return true;
93
+ if (plan.actions.length !== plan.writeSet.length) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan actions/writeSet lengths differ");
94
+ for (let index = 0; index < plan.actions.length; index += 1) {
95
+ const action = plan.actions[index];
96
+ const write = plan.writeSet[index];
97
+ const normalized = path.posix.normalize(action.target);
98
+ if (normalized !== action.target || normalized.startsWith("../") || path.isAbsolute(action.target)) throw invalidParamsError("plan target must be a normalized contained path");
99
+ if (action.sequence !== index + 1 || action.categoryId !== plan.pathCategory.id || action.scope !== plan.pathCategory.scope || action.anchor !== plan.pathCategory.anchor || !action.target.startsWith(`${plan.pathCategory.relPath}/`) || canonicalJson(write) !== canonicalJson({ categoryId: action.categoryId, scope: action.scope, anchor: action.anchor, target: action.target, sourceSha256: action.sourceSha256 })) {
100
+ throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan action, target, writeSet, category and anchor are inconsistent");
101
+ }
102
+ }
103
+ return true;
104
+ }
105
+
106
+ export function refuseHostApply() {
107
+ throw invalidParamsError("host apply is not implemented in the read-only Phase D slice", { action: "apply", stableRefusal: true });
108
+ }
package/src/index.mjs CHANGED
@@ -51,7 +51,17 @@ import {
51
51
  loadLicensingProfile,
52
52
  } from "./licensing.mjs";
53
53
  import { runProjection, loadProjectionManifest } from "./projection.mjs";
54
+ import { checkReportAction, renderReportAction } from "./report.mjs";
54
55
  import { scaffoldTarget } from "./scaffold.mjs";
56
+ import {
57
+ assertPlanConsistency,
58
+ buildHostAdapter,
59
+ materializeHostBuild,
60
+ planHost,
61
+ probeHost,
62
+ refuseHostApply,
63
+ } from "./host.mjs";
64
+ import { describeHost, loadHostRegistry } from "./host-profiles.mjs";
55
65
  import {
56
66
  describeSkeletonFiles,
57
67
  IDENTITY_RECORD_PATH,
@@ -109,13 +119,13 @@ export const COMMAND_SIDE_EFFECTS = Object.freeze({
109
119
  projection: Object.freeze({
110
120
  summary: "投影受管生成物。",
111
121
  sideEffect:
112
- "writes only manifest-authorized managed artifacts; unauthorized, handwritten, escaping, or conflicting paths are refused with zero writes",
122
+ "writes only manifest-authorized managed artifacts; the report sub-action (projection report) writes only the explicitly named --out/--binding paths contained in --root (Markdown goes to stdout when no --out is given); unauthorized, handwritten, escaping, or conflicting paths are refused with zero writes",
113
123
  exitCodes: "0 成功;2 拒绝/用法/机制错误",
114
124
  }),
115
125
  check: Object.freeze({
116
126
  summary: "契约/漂移/闭包/版本/文档事实/Git 前置状态诊断。",
117
127
  sideEffect:
118
- "none — diagnosis only: never writes, never auto-fixes; git is probed read-only (one frozen status query at most)",
128
+ "none — diagnosis only: never writes, never auto-fixes; git is probed read-only (one frozen status query at most); the report sub-action (check report) grades one rendered report against its bound machine result and writes nothing",
119
129
  exitCodes: "0 无发现;1 有发现;2 拒绝/用法/机制错误",
120
130
  }),
121
131
  });
@@ -183,6 +193,8 @@ export {
183
193
  HANDOFF_FIELDS,
184
194
  runProjection,
185
195
  loadProjectionManifest,
196
+ renderReportAction,
197
+ checkReportAction,
186
198
  runChecks,
187
199
  CHECK_CLASSES,
188
200
  DOCUMENT_STATES,
@@ -204,6 +216,14 @@ export {
204
216
  PROJECTION_MANIFEST_PATH,
205
217
  IDENTITY_RECORD_PATH,
206
218
  matchAnyGlob,
219
+ describeHost,
220
+ loadHostRegistry,
221
+ probeHost,
222
+ buildHostAdapter,
223
+ materializeHostBuild,
224
+ planHost,
225
+ assertPlanConsistency,
226
+ refuseHostApply,
207
227
  };
208
228
  export { ContractsError };
209
229