abelien-harness 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.env.example +4 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +10 -5
  11. package/skills/abelien-hardware/domain.json +3 -3
  12. package/skills/abelien-hardware/references/search-index-format.md +13 -1
  13. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  14. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  15. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  16. package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
  17. package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
  18. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
  19. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  20. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  21. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  22. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  23. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
  24. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
  25. package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
  26. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
  28. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  29. package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
  30. package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
  31. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  32. package/skills/abelien-hardware-selection/domain.json +2 -2
  33. package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
  34. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
  35. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  36. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  37. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  38. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  39. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  40. package/skills/abelien-reference/SKILL.md +51 -0
  41. package/skills/abelien-reference/domain.json +92 -0
  42. package/skills/abelien-reference/fixtures/create.final.json +102 -0
  43. package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
  44. package/skills/abelien-reference/fixtures/restore.final.json +267 -0
  45. package/skills/abelien-reference/references/research-contract.md +74 -0
  46. package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
  47. package/skills/abelien-reference/scripts/evidence.mjs +60 -0
  48. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  49. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  50. package/skills/abelien-reference/scripts/png.mjs +86 -0
  51. package/skills/abelien-reference/scripts/research.mjs +179 -0
  52. package/skills/abelien-structure/SKILL.md +2 -2
  53. package/skills/abelien-structure/domain.json +64 -0
  54. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  55. package/skills/abelien-structure/references/handoff.md +1 -1
  56. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  57. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  58. package/skills/abelien-structure/worker/cad.py +1623 -0
  59. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  60. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  61. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  62. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  63. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  64. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  65. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  66. package/skills/abelien-structure/worker/config.mjs +6 -0
  67. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  68. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  69. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  70. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  71. package/skills/abelien-structure/worker/engineering.py +324 -0
  72. package/skills/abelien-structure/worker/identity.mjs +98 -0
  73. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  74. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  75. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  76. package/skills/abelien-structure/worker/main.mjs +102 -0
  77. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  78. package/skills/abelien-structure/worker/materials.mjs +52 -0
  79. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  80. package/skills/abelien-structure/worker/native.mjs +157 -0
  81. package/skills/abelien-structure/worker/representation.mjs +47 -0
  82. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  83. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  84. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  85. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  86. package/skills/abelien-workspace/SKILL.md +11 -0
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  88. package/skills/abelien-workspace/domain.json +25 -2
  89. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  90. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  91. package/skills/abelien-workspace/references/user-journey.md +10 -0
  92. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  93. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  94. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  95. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  96. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  97. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  98. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  99. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  100. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  101. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  102. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
  103. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
  104. package/skills/abelien-workspace/workflows/user-journey-hardware.json +5 -4
@@ -21,6 +21,12 @@ function parseJson(bytes, code, key) {
21
21
  catch { throw releaseError(code, `invalid JSON object: ${key}`, { key }); }
22
22
  }
23
23
 
24
+ // 回滚点只作记录:旧 catalog 坏了也不阻断本次激活,release_id 记 null。
25
+ function previousReleaseId(object) {
26
+ try { return JSON.parse(Buffer.from(object.bytes).toString("utf8")).release_id ?? null; }
27
+ catch { return null; }
28
+ }
29
+
24
30
  function safeFile(root, relative) {
25
31
  const base = path.resolve(root);
26
32
  const file = path.resolve(base, relative);
@@ -133,10 +139,7 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
133
139
  if (actual !== entry.sha256) throw releaseError("REMOTE_WIKI_MISMATCH", "remote Wiki SHA-256 does not match Manifest", { key, expected_sha256: entry.sha256, actual_sha256: actual });
134
140
  });
135
141
  const key = `${config.prefix}${catalog.source_manifest.object_key || "_manifest.json"}`;
136
- await putTosObject(config, key, manifestBytes, {
137
- fetchImpl,
138
- headers: { "content-type": "application/json", "if-none-match": "*", "x-amz-meta-sha256": sha256(manifestBytes) },
139
- });
142
+ await putImmutable(config, key, manifestBytes, fetchImpl);
140
143
  const uploaded = await getTosObject(config, key, { fetchImpl });
141
144
  if (sha256(uploaded.bytes) !== catalog.source_manifest.sha256) {
142
145
  throw releaseError("SOURCE_MANIFEST_MISMATCH", "uploaded Wiki Manifest SHA-256 mismatch", { key });
@@ -144,6 +147,15 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
144
147
  return { key, etag: uploaded.etag, sha256: catalog.source_manifest.sha256, bootstrapped: true, verified_wiki_objects: manifest.length };
145
148
  }
146
149
 
150
+ function objectContentType(key) {
151
+ if (key.endsWith(".gz")) return "application/gzip";
152
+ if (key.endsWith(".md")) return "text/markdown; charset=utf-8";
153
+ return "application/json";
154
+ }
155
+
156
+ // 阿里云 OSS 的 S3 兼容写入没有条件 PUT:If-None-Match / If-Match 一律 400 NotImplemented,
157
+ // x-oss-forbid-overwrite 被忽略照常覆盖。不可变对象因此靠"先读后写 + 回读 SHA":键按内容寻址,
158
+ // 并发写同一键只会写入同一份字节;已存在但字节不同即冲突失败,不覆盖。
147
159
  async function putImmutable(config, key, bytes, fetchImpl) {
148
160
  const expected = sha256(bytes);
149
161
  const existing = await getTosObject(config, key, { fetchImpl, optional: true });
@@ -152,14 +164,10 @@ async function putImmutable(config, key, bytes, fetchImpl) {
152
164
  if (actual !== expected) throw releaseError("IMMUTABLE_OBJECT_CONFLICT", "release object already exists with different bytes", { key, expected_sha256: expected, actual_sha256: actual });
153
165
  return { key, sha256: expected, reused: true };
154
166
  }
155
- try {
156
- await putTosObject(config, key, bytes, {
157
- fetchImpl,
158
- headers: { "content-type": key.endsWith(".gz") ? "application/gzip" : "application/json", "if-none-match": "*" },
159
- });
160
- } catch (cause) {
161
- if (cause.code !== "TOS_HTTP_STATUS" || cause.details?.status !== 412) throw cause;
162
- }
167
+ await putTosObject(config, key, bytes, {
168
+ fetchImpl,
169
+ headers: { "content-type": objectContentType(key), "x-amz-meta-sha256": expected },
170
+ });
163
171
  const uploaded = await getTosObject(config, key, { fetchImpl });
164
172
  const actual = sha256(uploaded.bytes);
165
173
  if (actual !== expected) throw releaseError("INDEX_UPLOAD_VERIFY_FAILED", "uploaded release object SHA-256 mismatch", { key, expected_sha256: expected, actual_sha256: actual });
@@ -290,11 +298,15 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
290
298
  const uploaded = await mapConcurrent(local.files, Number.isFinite(limit) ? limit : 6, item =>
291
299
  putImmutable(config, `${config.prefix}${item.key}`, item.bytes, fetchImpl));
292
300
 
293
- const catalogHeaders = {
294
- "content-type": "application/json",
295
- ...(previousCatalog ? { "if-match": `"${previousCatalog.etag}"` } : { "if-none-match": "*" }),
296
- };
297
- await putTosObject(config, catalogKey, local.catalogBytes, { fetchImpl, headers: catalogHeaders });
301
+ // OSS 没有条件 PUT(见 putImmutable),激活前按内容 SHA 重读比对;OSS 对 JSON 对象不回 ETag,
302
+ // 故不能比 ETag。重读到写入之间仍有窗口,写后 verify 的 release_id 比对兜住输掉的竞争。
303
+ const previousSha256 = previousCatalog ? sha256(previousCatalog.bytes) : null;
304
+ const currentCatalog = await getTosObject(config, catalogKey, { fetchImpl, optional: true });
305
+ const currentSha256 = currentCatalog ? sha256(currentCatalog.bytes) : null;
306
+ if (currentSha256 !== previousSha256) {
307
+ throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog changed while this release was uploading", { expected_sha256: previousSha256, actual_sha256: currentSha256 });
308
+ }
309
+ await putTosObject(config, catalogKey, local.catalogBytes, { fetchImpl, headers: { "content-type": "application/json" } });
298
310
  const verified = await verifyPublishedIndex({ env, fetchImpl, concurrency: limit });
299
311
  if (verified.release_id !== local.catalog.release_id) {
300
312
  throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog is not the release just published", { expected_release_id: local.catalog.release_id, actual_release_id: verified.release_id });
@@ -308,6 +320,7 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
308
320
  uploaded_shards: uploaded.filter(item => !item.reused).length,
309
321
  reused_shards: uploaded.filter(item => item.reused).length,
310
322
  catalog_key: catalogKey,
323
+ previous_catalog: previousCatalog ? { sha256: previousSha256, release_id: previousReleaseId(previousCatalog) } : null,
311
324
  verified,
312
325
  smoke: smokeResult,
313
326
  };
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
- export const canonical = value => value === null || typeof value !== "object" ? JSON.stringify(value)
3
- : Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
4
- : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
5
- export const digest = value => createHash("sha256").update(canonical(value)).digest("hex");
1
+ // One canonical/digest for every evidence hash: the kernel writes Volc receipts with it, so verification must use the same function.
2
+ import { canonical, digest } from "../../abelien-harness/scripts/web/common.mjs";
3
+ import { validateFitEnvelope } from "./dimensions.mjs";
4
+ export { canonical, digest };
6
5
  // Bind recall to complete requirements and slots, never an Agent summary.
7
6
  export const selectionRequestDigest = input => digest({ requirements: input.requirements ?? {}, slots: input.slots ?? [] });
8
7
 
@@ -19,6 +18,8 @@ export function validateSelectionSlots(slots) {
19
18
  || !validCategoryPath(slot.category_path)
20
19
  || !slot.hard_constraints || typeof slot.hard_constraints !== "object" || Array.isArray(slot.hard_constraints))
21
20
  invalid(`slots[${index}] needs slot_id, function, explicit integer quantity, three nonempty category levels (no null), and hard_constraints object; read level1/level2 guides before correcting the reply`);
21
+ // Optional structure zone budget (P2-06.03); its absence keeps the original behavior.
22
+ if (slot.fit_envelope !== undefined) validateFitEnvelope(slot.fit_envelope);
22
23
  }
23
24
  return slots;
24
25
  }
@@ -1,5 +1,6 @@
1
1
  import zlib from "node:zlib";
2
- import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateRequirementEnvelopes } from "./dimensions.mjs";
2
+ import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateFitEnvelope, validateRequirementEnvelopes } from "./dimensions.mjs";
3
+ import { electricalFields } from "./electrical.mjs";
3
4
  import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
4
5
  import { createTosConfig, getTosObject, sha256 } from "./tos-object-store.mjs";
5
6
 
@@ -82,13 +83,15 @@ export function parseHardwareWiki(content) {
82
83
  "schema", "type", "title", "description", "manufacturer", "model", "aliases", "resource_urn", "record_id",
83
84
  "record_version", "status", "category_path", "category_leaf", "primary_function", "integration_level",
84
85
  "hardware_form", "component_kind", "functional_capabilities", "capability_coverage", "source_url", "source_updated_at",
85
- "dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces",
86
+ "dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces", "electrical", "electrical_source",
86
87
  ];
87
88
  const meta = {};
88
89
  for (const key of wanted) {
89
- const line = frontMatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
90
- if (line) {
91
- if (key === "dimension_source" || key === "dimensions_mm") {
90
+ // Same-line value only: \s* would cross the newline and read the next line as this key's value.
91
+ // An empty value (including YAML block style) is treated as absent, as before these keys were read.
92
+ const line = frontMatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"));
93
+ if (line && line[1].trim()) {
94
+ if (["dimension_source", "dimensions_mm", "electrical", "electrical_source"].includes(key)) {
92
95
  try { meta[key] = JSON.parse(line[1]); }
93
96
  catch { throw error("WIKI_INVALID", `${key} must be generated inline JSON`); }
94
97
  } else meta[key] = scalar(line[1]);
@@ -234,6 +237,7 @@ function validateInput(input) {
234
237
  if (!slot?.slot_id || !validCategoryPath(slot.category_path)) {
235
238
  throw error("VALIDATION", "each hardware search slot needs slot_id and three nonempty category_path levels (no null)");
236
239
  }
240
+ if (slot.fit_envelope !== undefined) validateFitEnvelope(slot.fit_envelope);
237
241
  }
238
242
  }
239
243
 
@@ -270,6 +274,7 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
270
274
  title: meta.title,
271
275
  name: meta.title,
272
276
  ...dimensionFields(meta),
277
+ ...electricalFields(meta),
273
278
  ...(meta.interfaces !== undefined ? { interfaces: meta.interfaces } : {}),
274
279
  category_path: meta.category_path,
275
280
  integration_level: meta.integration_level,
@@ -347,7 +352,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
347
352
  }
348
353
  const index = await loadIndex(descriptor);
349
354
  const semanticQuery = semanticQueryFor(input, slot);
350
- const screen = screenDimensions(index.records, input.requirements);
355
+ const screen = screenDimensions(index.records, input.requirements, slot);
351
356
  const rankedIndex = screen.survivors.map(({ record: entry, check }) => ({
352
357
  entry, check,
353
358
  ranking: scoreIndexEntry(entry, semanticQuery, input.policy?.prefer_system_candidates !== false),
@@ -359,7 +364,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
359
364
  const candidates = orderByDimensionTier(loaded.map(({ document, indexRanking }) => {
360
365
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
361
366
  const candidate = candidateFromDocument(document, config, ranking, includeContent, indexRanking.score);
362
- return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
367
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements, slot) };
363
368
  }).sort((a, b) => b.semantic_score - a.semantic_score || b.index_score - a.index_score || String(a.model).localeCompare(String(b.model))), candidate => candidate.dimension_check.status);
364
369
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
365
370
  results.push({
@@ -405,7 +410,7 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, m
405
410
  const candidates = shortlistByDimensionTier(loaded.map(document => {
406
411
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
407
412
  const candidate = candidateFromDocument(document, config, ranking, includeContent, ranking.score);
408
- return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
413
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements, slot) };
409
414
  }).filter(candidate => candidate.dimension_check.status !== "incompatible").sort((a, b) => b.semantic_score - a.semantic_score || String(a.model).localeCompare(String(b.model))), topK, maxFetch, candidate => candidate.dimension_check.status);
410
415
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
411
416
  results.push({
@@ -1,28 +1,11 @@
1
- import { failure, text, timed } from "./web-common.mjs";
2
- import { sha256 } from "./public-page.mjs";
3
- import { digest } from "./selection-request.mjs";
1
+ // Hardware binding of the kernel Volc client (P2-13.03). Codes, messages and receipts are unchanged.
2
+ import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
4
3
  import { redactHardwareResponse } from "./response-redaction.mjs";
5
- import { receiptRequestId, validVolcResult } from "./web-receipt.mjs";
6
- export const volcQueryRequest = item => ({ Query: item.query, SearchType: "web", Count: item.count ?? 10, Filter: { NeedUrl: true, NeedContent: false }, ContentFormats: "markdown", QueryControl: { QueryRewrite: false } });
7
- export function validateVolcCapability(body) {
8
- if (!text(body.Query) || !Number.isSafeInteger(body.Count) || body.Count < 1 || body.Count > 50) throw failure("HARDWARE_WEB_CAPABILITY", "Volc Query must be nonempty and Count 1–50; original input is retained without truncation");
9
- }
10
- export async function volcWebSearch(body, { env, fetchImpl = fetch, timeoutMs }) {
11
- validateVolcCapability(body);
12
- const key = env.WEB_SEARCH_API_KEY;
13
- if (!text(key) || /[\r\n\0]/.test(key)) throw failure("HARDWARE_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
14
- try {
15
- return await timed(async signal => {
16
- const response = await fetchImpl("https://open.feedcoopapi.com/search_api/web_search", { method: "POST", redirect: "error", signal, headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
17
- const raw = redactHardwareResponse(await response.text(), env), saved = { raw, raw_sha256: sha256(raw), response: null, response_sha256: null, request_id: null, http_status: response.status, received_at: new Date().toISOString() };
18
- try { saved.response = JSON.parse(raw); saved.response_sha256 = digest(saved.response); saved.request_id = receiptRequestId({ ...saved, provider: "volcengine" }); } catch { /* Keep full received text. */ }
19
- const reject = (code, message, unknown = false) => { const error = failure(code, message, { http_status: response.status }); Object.defineProperties(error, { providerEvidence: { value: saved }, ambiguous: { value: unknown } }); throw error; };
20
- if (response.status >= 500) reject("HARDWARE_WEB_PROVIDER", "Volc server outcome is unknown", true);
21
- if (response.status < 200 || response.status >= 300) reject([401, 403].includes(response.status) ? "HARDWARE_WEB_AUTH" : response.status === 429 ? "HARDWARE_WEB_RATE_LIMIT" : "HARDWARE_WEB_PROVIDER", "Volc HTTP request rejected");
22
- const error = saved.response?.ResponseMetadata?.Error;
23
- if (error) reject("HARDWARE_WEB_PROVIDER", "Volc reported an explicit error", !/^(104\d\d|700429)$/.test(String(error.Code ?? error.CodeN)));
24
- if (!validVolcResult(saved.response)) reject("HARDWARE_WEB_RESPONSE", "Volc result is malformed or ambiguous", true);
25
- return saved;
26
- }, timeoutMs);
27
- } catch (error) { if (error?.code?.startsWith("HARDWARE_WEB_")) throw error; throw failure("HARDWARE_WEB_TRANSPORT", "Volc transport outcome is unknown"); }
28
- }
4
+ const volc = createVolcSearch({
5
+ codes: { capability: "HARDWARE_WEB_CAPABILITY", config: "HARDWARE_WEB_CONFIG", provider: "HARDWARE_WEB_PROVIDER", auth: "HARDWARE_WEB_AUTH",
6
+ rateLimit: "HARDWARE_WEB_RATE_LIMIT", response: "HARDWARE_WEB_RESPONSE", transport: "HARDWARE_WEB_TRANSPORT", timeout: "HARDWARE_WEB_TIMEOUT" },
7
+ ownsError: error => Boolean(error?.code?.startsWith("HARDWARE_WEB_")),
8
+ redact: redactHardwareResponse,
9
+ timeoutMessage: "hardware network request timed out",
10
+ });
11
+ export const { volcMaxCount, volcQueryRequest, legacyVolcQueryRequest, validateVolcCapability, volcWebSearch } = volc;
@@ -1,9 +1,7 @@
1
+ // Generic helpers live in the kernel (P2-13.03); this module binds hardware's codes and keeps hardware-only checks.
1
2
  import { selectionRequestDigest, digest, validateSelectionSlots } from "./selection-request.mjs";
2
- export const record = value => value && typeof value === "object" && !Array.isArray(value);
3
- export const text = value => typeof value === "string" && value.trim().length > 0;
4
- export function failure(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
5
- export function invalid(message) { throw failure("VALIDATION", message); }
6
- export function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
3
+ import { record, text, failure, invalid, timed as kernelTimed } from "../../abelien-harness/scripts/web/common.mjs";
4
+ export { record, text, failure, invalid, onlyKeys } from "../../abelien-harness/scripts/web/common.mjs";
7
5
  export async function boundRecall(input, readResource) {
8
6
  if (typeof readResource !== "function" || !/^hardware-recall:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input?.recall_id ?? "")) invalid("persisted recall_id and trusted resource reader required");
9
7
  if (!record(input.requirements)) invalid("original requirements required");
@@ -22,12 +20,11 @@ export function positiveEnv(env, name, fallback) {
22
20
  export function webConfig(env = {}) {
23
21
  const dnsTransport = env.ABELIEN_HARDWARE_WEB_DNS_TRANSPORT || "system";
24
22
  if (!["system", "doh"].includes(dnsTransport)) throw failure("HARDWARE_WEB_CONFIG", "web DNS transport must be system or doh");
25
- return { dnsTransport, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
23
+ // Prismer (query, URL-load and Parse) is temporarily off by default (P2-05.02).
24
+ const prismer = env.ABELIEN_HARDWARE_WEB_PRISMER || "disabled";
25
+ if (!["enabled", "disabled"].includes(prismer)) throw failure("HARDWARE_WEB_CONFIG", "Prismer switch must be enabled or disabled");
26
+ return { dnsTransport, prismer, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
26
27
  pageTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PAGE_TIMEOUT_MS", 30000),
27
28
  parseTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PARSE_TIMEOUT_MS", 120000) };
28
29
  }
29
- export async function timed(operation, timeoutMs, code = "HARDWARE_WEB_TIMEOUT") {
30
- const controller = new AbortController(); let timer;
31
- try { return await Promise.race([operation(controller.signal), new Promise((_, reject) => { timer = setTimeout(() => { reject(failure(code, "hardware network request timed out")); controller.abort(); }, timeoutMs); })]); }
32
- finally { clearTimeout(timer); }
33
- }
30
+ export const timed = (operation, timeoutMs, code = "HARDWARE_WEB_TIMEOUT") => kernelTimed(operation, timeoutMs, code, "hardware network request timed out");
@@ -115,7 +115,7 @@ export function verifyCheckpointExtension(checkpoint, latest, readBlob) {
115
115
  try {
116
116
  const before = checkpoint?.body, after = latest?.body;
117
117
  if (!before || !after || checkpoint.id !== latest.id || checkpoint.kind !== "hardware-web-evidence" || latest.kind !== checkpoint.kind
118
- || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots"])
118
+ || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots", "prismer_switch"])
119
119
  || !prefix(before.query_plans, after.query_plans) || !prefix(before.unknowns, after.unknowns)
120
120
  || !prefix(before.searches, after.searches, before.protocol === chainProtocol ? group : attempt) || !prefix(before.pages, after.pages, page)) reject();
121
121
  } catch { reject(); }
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
6
6
  import { volcQueryRequest, validateVolcCapability, volcWebSearch } from "./volc-web-search.mjs";
7
7
  import { jsonReceipt } from "./web-receipt.mjs";
8
8
  import { redactHardwareResponse } from "./response-redaction.mjs";
9
- import { chainPolicy, chainProtocol, receiptFields, primaryQuery, sourceOperation, operationId, permitsFallback, expectedSelection, selectedReceipt, queryResults, projectQuery, sourceProjection, trustedResult, validateChainIntegrity } from "./provider-chain-contract.mjs";
9
+ import { chainPolicy, chainProtocol, pinPrismerSwitch, receiptFields, primaryQuery, sourceOperation, operationId, permitsFallback, expectedSelection, selectedReceipt, queryResults, projectQuery, sourceProjection, trustedResult, validateChainIntegrity } from "./provider-chain-contract.mjs";
10
10
 
11
11
  // An effect is dispatched only after its original attempt has been saved. Receipt
12
12
  // persistence is outside the request catch: a storage failure cannot authorize fallback.
@@ -29,6 +29,7 @@ export async function searchProviderChain({ input, source, existing, env, readRe
29
29
  // Parse observations update the authoritative array and projection together.
30
30
  for (const page of evidence.pages) if (page.selected_attempt_id) page.attempt = selectedReceipt(page);
31
31
  const config = input.unresolved_slots.length ? webConfig(env) : null;
32
+ if (config) config.prismer = pinPrismerSwitch(evidence, config.prismer);
32
33
  const request = (path, body, timeoutMs, method = "POST") => prismerRequest(path, { body, env, fetchImpl, timeoutMs, method });
33
34
  const redact = raw => redactHardwareResponse(raw, env);
34
35
  const checkUrl = url => {
@@ -76,8 +77,13 @@ export async function searchProviderChain({ input, source, existing, env, readRe
76
77
  if (attempt.kind === "parse" && text(receipt.response?.taskId)) { attempt.task_id = receipt.response.taskId; attempt.status = "pending"; }
77
78
  await save();
78
79
  };
80
+ const skipPrismer = async attempt => { Object.assign(attempt, { status: "unavailable", reason: "prismer_disabled" }); await save(); };
79
81
  const chooseQuery = async (entry, item) => {
80
- if (!entry.attempts.length) { const attempt = makeAttempt(entry, "prismer", "query", entry.request); await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs)); }
82
+ if (!entry.attempts.length) {
83
+ const attempt = makeAttempt(entry, "prismer", "query", entry.request);
84
+ if (config.prismer === "disabled") await skipPrismer(attempt);
85
+ else await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs));
86
+ }
81
87
  if (permitsFallback(entry.attempts[0], true) && entry.attempts.length === 1) { const attempt = makeAttempt(entry, "volcengine", "query", volcQueryRequest(item)); await dispatch(attempt, () => volcWebSearch(attempt.request, { env, fetchImpl, timeoutMs: config.timeoutMs })); }
82
88
  const selected = expectedSelection(entry, true);
83
89
  if (!selected) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "original query effect is not known selectable");
@@ -131,7 +137,8 @@ export async function searchProviderChain({ input, source, existing, env, readRe
131
137
  const operation = sourceOperation(page.source_url);
132
138
  if (!page.attempts.length) {
133
139
  const attempt = makeAttempt(page, "prismer", operation.kind, operation.request);
134
- await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
140
+ if (config.prismer === "disabled") await skipPrismer(attempt);
141
+ else await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
135
142
  }
136
143
  if (permitsFallback(page.attempts[0], false) && page.attempts.length === 1) {
137
144
  const attempt = makeAttempt(page, "direct_https", "direct_page", { url: page.source_url, method: "GET" });
@@ -1,8 +1,11 @@
1
1
  import { digest } from "./selection-request.mjs";
2
2
  import { text } from "./web-common.mjs";
3
3
  import { sha256 } from "./public-page.mjs";
4
+ import { validVolcResult, volcRequestId } from "../../abelien-harness/scripts/web/volc.mjs";
5
+ export { validVolcResult };
4
6
  export const receiptRequestId = attempt => {
5
- const id = attempt?.provider === "volcengine" ? attempt.response?.ResponseMetadata?.RequestId : attempt?.response?.requestId;
7
+ if (attempt?.provider === "volcengine") return volcRequestId(attempt.response);
8
+ const id = attempt?.response?.requestId;
6
9
  return text(id) ? id : null;
7
10
  };
8
11
  export function intactReceipt(attempt) {
@@ -14,7 +17,6 @@ export const successfulReceipt = attempt => intactReceipt(attempt) && attempt.ht
14
17
  && (attempt.provider === "volcengine" ? validVolcResult(attempt.response)
15
18
  : attempt.provider === "direct_https" ? ["read", "unavailable"].includes(attempt.response?.status)
16
19
  : attempt.response?.success === true && !attempt.response.error);
17
- export const validVolcResult = response => text(response?.ResponseMetadata?.RequestId) && !response.ResponseMetadata.Error && response.success !== false && Array.isArray(response.Result?.WebResults);
18
20
  export function jsonReceipt(response, httpStatus, provider, envRedact) {
19
21
  const raw = envRedact(JSON.stringify(response)), value = JSON.parse(raw);
20
22
  const saved = { raw, raw_sha256: sha256(raw), response: value, response_sha256: digest(value), received_at: new Date().toISOString(), http_status: httpStatus };
@@ -1,9 +1,17 @@
1
1
  import { onlyKeys, text, invalid, boundRecall } from "./web-common.mjs";
2
+ import { volcMaxCount } from "./volc-web-search.mjs";
3
+
4
+ const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
2
5
 
3
6
  // Shared read-only plan validation. Do not normalize model intent or access
4
7
  // credentials/configuration/checkpoints before this original-source binding.
5
- export async function validateHardwareWebPlan({ input, readResource }) {
6
- onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots"], "web search input");
8
+ // The shape check alone is used for a plan already frozen in saved evidence.
9
+ // plan_rules is an explicit opt-in written by the current workflow definitions; a run pinned
10
+ // to an older definition omits it and keeps the rules its definition was published with.
11
+ export const currentPlanRules = "volc-english-5";
12
+ export async function validateFrozenHardwareWebPlan({ input, readResource }) {
13
+ onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots", "plan_rules"], "web search input");
14
+ if (Object.hasOwn(input, "plan_rules") && input.plan_rules !== currentPlanRules) invalid(`plan_rules must be ${currentPlanRules}`);
7
15
  const source = await boundRecall(input, readResource), recall = source.body;
8
16
  if (!Array.isArray(input.unresolved_slots)) invalid("unresolved_slots must be an array");
9
17
  const unresolved = new Set();
@@ -16,3 +24,14 @@ export async function validateHardwareWebPlan({ input, readResource }) {
16
24
  for (const slot of input.slots) if (!(recall.results.find(result => result.slot_id === slot.slot_id)?.candidates?.length) && !unresolved.has(slot.slot_id)) invalid("every zero-candidate slot needs an unresolved query; preserve the requirement");
17
25
  return { source };
18
26
  }
27
+ // New plans (P2-05.02): English queries for Volc and at most 5 results per query.
28
+ export function validateNewHardwareWebPlan(input) {
29
+ for (const item of input.unresolved_slots) {
30
+ if (item.count !== undefined && item.count > volcMaxCount) invalid(`search Count must be an integer from 1 to ${volcMaxCount}`);
31
+ if (cjk.test(item.query)) invalid("search query must be written in English; keep model numbers and manufacturer names");
32
+ }
33
+ }
34
+ export async function validateHardwareWebPlan({ input, readResource }) {
35
+ const result = await validateFrozenHardwareWebPlan({ input, readResource });
36
+ validateNewHardwareWebPlan(input); return result;
37
+ }
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
6
6
  import { queryRequest, validateHardwareWebIntegrity, verifyCheckpointExtension, trustedParseResult } from "./web-integrity.mjs";
7
7
  import { chainProtocol } from "./provider-chain-contract.mjs";
8
8
  import { searchProviderChain } from "./web-provider-chain.mjs";
9
- import { validateHardwareWebPlan } from "./web-search-plan.mjs";
9
+ import { validateFrozenHardwareWebPlan, validateNewHardwareWebPlan, currentPlanRules } from "./web-search-plan.mjs";
10
10
  const provider = "prismer", protocol = "prismer-context-parse-v1";
11
11
  const unresolvedAttempt = attempt => ["in_progress", "unknown", "failed"].includes(attempt?.status);
12
12
  export async function verifyHardwareWebRecovery({ input, readResource, readBlob }) {
@@ -16,13 +16,16 @@ export async function verifyHardwareWebRecovery({ input, readResource, readBlob
16
16
  await unchanged(source, readResource); await unchanged(saved, readResource); return { ok: true };
17
17
  }
18
18
  export async function searchHardwareWeb({ input, env = {}, readResource, fetchImpl = fetch, ports = {}, checkpoint, readBlob, writeBlob }) {
19
- const { source } = await validateHardwareWebPlan({ input, readResource });
19
+ const { source } = await validateFrozenHardwareWebPlan({ input, readResource });
20
20
  const binding = { recall_id: source.id, recall_digest: digest(source), request_digest: selectionRequestDigest(input), requirements: input.requirements, slots: input.slots };
21
21
  const planDigest = digest(input.unresolved_slots), id = `web-evidence-${digest({ recall_id: binding.recall_id, request_digest: binding.request_digest }).slice(0, 24)}`;
22
22
  const storedExisting = await readResource(`hardware-web-evidence:${id}`);
23
23
  const existing = resolveWebEvidence(storedExisting, readBlob);
24
24
  const pack = body => packWebEvidence(body, writeBlob, !existing && env.AB_HARNESS_AUDIT_V1 === "true");
25
25
  if (existing && (existing.kind !== "hardware-web-evidence" || existing.body?.recall_id !== binding.recall_id || existing.body.request_digest !== binding.request_digest)) invalid("existing evidence has a different original identity");
26
+ // A plan frozen in saved evidence keeps the rules it was accepted under; an input without
27
+ // plan_rules comes from a run pinned to a pre-P2-05.02 definition and keeps its old rules.
28
+ if (input.plan_rules === currentPlanRules && existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
26
29
  if (!existing || existing.body.protocol === chainProtocol) return searchProviderChain({ input, source, existing: storedExisting, env, readResource, fetchImpl, ports, checkpoint, readBlob, writeBlob });
27
30
  if (existing && (existing.body.provider !== provider || existing.body.protocol !== protocol)) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "retain the original provider evidence; provider migration cannot reissue its invocation");
28
31
  validateHardwareWebIntegrity({ original: input, source, saved: storedExisting, readBlob, allowPartial: true, code: "HARDWARE_WEB_RECONCILIATION_REQUIRED" });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: abelien-hardware-selection
3
- description: 为已澄清的需求分层选择硬件分类,经真实 TOS 召回、固定 Prismer 优先/Volc 降级的来源绑定补选、Agent 语义比较与本地 hardware.select 校验身份、数量和引用。用于硬件选型,不用于需求澄清、独立数据库兼容求解或实物验证。
3
+ description: 为已澄清的需求分层选择硬件分类,经真实 TOS 召回、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、Agent 语义比较与本地 hardware.select 校验身份、数量和引用。用于硬件选型,不用于需求澄清、独立数据库兼容求解或实物验证。
4
4
  license: MIT
5
5
  compatibility:
6
6
  - claude-code
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "abelien-hardware-selection",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "本地硬件选型:真实 TOS 召回、Agent 证据判断与受信资源校验;不是数据库兼容性求解器",
5
5
  "resources": ["hardware-selection"],
6
6
  "requires": ["abelien-harness", "abelien-hardware"],
7
7
  "capabilities": {
8
8
  "hardware.select": {
9
- "description": "回读同需求的真实召回资源,验证 Agent 选择的型号、数量、逐约束引用与兼容评估,保存选型及可选尺寸占用摘要;未知不通过门禁",
9
+ "description": "回读同需求的真实召回资源,验证 Agent 选择的型号、数量、逐约束引用与兼容评估(电源与接口可由可信电气数据确定性判定,其未知仍阻断;结构/散热/软件只提示并列入 advisories),保存选型、可选尺寸占用摘要与兼容报告",
10
10
  "adapter": {
11
11
  "type": "module",
12
12
  "path": "scripts/select.mjs",
@@ -6,7 +6,7 @@ load-guide 的各层响应均附 runtimeBoundary 提示当前执行范围;只
6
6
 
7
7
  ## 请求和来源
8
8
 
9
- 请求仅含 requirements、slots、recall_id、selection_proposal。requirements 原样保留 clarify 的 context/reaction/limits;槽位带 slot_id/function/quantity/category_path[3]/hard_constraints 对象。recall_id 必须指向调用 workdir 内真实 hardware-recall,校验器通过受信 readResource 前后读回,比对完整需求/槽位 request_digest。不能提交召回正文替代真实来源。旧召回没有 request_digest 时须重新检索,不得修改原资源。
9
+ 请求仅含 requirements、slots、recall_id、selection_proposal。requirements 原样保留 clarify 的 context/reaction/limits;槽位带 slot_id/function/quantity/category_path[3]/hard_constraints 对象,可选 fit_envelope(见下文「结构化尺寸与正式门禁」)。recall_id 必须指向调用 workdir 内真实 hardware-recall,校验器通过受信 readResource 前后读回,比对完整需求/槽位 request_digest。不能提交召回正文替代真实来源。旧召回没有 request_digest 时须重新检索,不得修改原资源。
10
10
 
11
11
  选型前校验全部需求条目:context 必须带非空 text 和 source=user|system;reaction 与每一项 limits 都必须是含唯一 id、非空 text 和上述 source 的对象,ID 在两组之间也不得重复。每项 limit 的 strength 必须是 hard|preference;null、数值、缺归属或拼错 strength 均明确以 VALIDATION 拒绝,不可在筛选 hard 时静默消失。合法的系统假设/偏好原样保留;未解决证据仍保存在 unknowns 并返回 needs_confirmation。
12
12
 
@@ -20,7 +20,7 @@ proposal 包含 status=verified|needs_confirmation、plan_mode=assemble_new|adap
20
20
  - quantity 显式给出且等于槽位数量,不默认补 1;每槽位最多一个型号。
21
21
  - selection_reason 非空;wiki_evidence 为完整 Wiki 中至少 8 字符的原文引用数组,不是 ID、ETag 或自由改写摘要。
22
22
  - constraint_checks 逐项包含 constraint_id、status=verified|unknown|incompatible、reason、evidence:[{component_id,quote}]。ID 覆盖 function、slot:<hard_constraints 的键>、limit:<每项 hard limit.id>。缺关键证据仍 unknown。
23
- - 多型号须逐 power/interfaces/mechanical/thermal/software 提供 compatibility_checks,包含 dimension/status/reason/evidence;每个维度引用每个所选型号的证据。
23
+ - 多型号须为 power、interfaces 提供 compatibility_checks(dimension/status/reason/evidence,每个所选型号都有原文证据),除非可信电气数据已确定性通过;mechanical、thermal、software 可选填写,只作提示,未填有效引用的列入可选 advisories。见下文“电气兼容与兼容报告”。
24
24
 
25
25
  constraint_checks 非数组、ID 重复或不属于当前槽位时仍以 VALIDATION 拒绝。error.message 列明 slot_id、expected(当前槽位的 function / slot 键与全局 hard limit ID)、received(完整原顺序,非数组为 null)、unexpected(去重、首次出现顺序)与 duplicate(去重、首次重复顺序),供既有 Web 预检 issues 原样反馈并修正。其他槽位具有某个 hard_constraints 键,不使它成为当前槽位的有效 ID;例如 desk-sensor 的 slot:no-stand 不能用于缺少该键的 weather-display。只缺少某项检查仍走 CONSTRAINT_UNVERIFIED、needs_confirmation,不因诊断改为结构拒绝;Wiki 归属、incompatible 和门禁语义不变。
26
26
 
@@ -61,8 +61,35 @@ V2 hardware evidence uses fixed prismer_then_volc ordered primary/fallback recei
61
61
 
62
62
  limits 可附 envelope;严格形状与来源规则见 [索引尺寸契约](../../abelien-hardware/references/search-index-format.md)。hardware.search 在分类所有索引记录上先筛尺寸再 Top-K,未知和估算保留。hardware.select 读取冻结 recall 并核对 request_digest、正文 SHA-256 后,复用纯判定器重算,忽略模型伪造的尺寸、来源、名称和 dimension_check。incompatible 以 VALIDATION 拒绝所选件,必须选其他存活候选;dimension_check.decision_required=true(用户 hard 上限+measured/datasheet 尺寸,且轴对齐装法全部失败,仍不足以证明放不下)时加入 DIMENSION_DECISION_REQUIRED 并返回 needs_confirmation,模型引用、确认或机械 verified 不能覆盖。unknown、估算尺寸、system/preference 上限或估算壁厚下的 risk 只随 selected_models.dimension_check 提示,不进入 unknowns,也不单独阻止 verified;尺寸限制仍须经原 constraint_checks 的 Wiki 证据门。
63
63
 
64
- 没有 envelope 时旧流程仍可在原 Wiki 证据范围 verified,但 selected_models.dimension_check 始终显式为 unknown(PRODUCT_ENVELOPE_MISSING),不能宣传尺寸检查通过。clear 也只表示单件粗筛;device_tested 仍 false。hardware.finalize 仍只返回 proposed/needs_confirmation 的提案,不创建硬件正式资源。
64
+ 槽位可附 `fit_envelope: {envelope, basis: locked|inferred, zone?}`,envelope 与 limits 的 envelope 同一校验,zone 为短标识;字段不合法以 VALIDATION 拒绝。它只对本槽位追加一个容器,原因的 `limit_id` 为 `fit:<slot_id>`,有 zone 时同时带 `zone`。locked 的权限与 user+hard 上限相同(可 incompatible、可 decision_required),inferred 与 system/preference 上限相同(只 risk)。它属于结构输入,不进入 constraint_checks 的 expected ID,由判定器确定性复核;槽位已计入 request_digest,改分区须重新检索。只有 fit_envelope、没有产品 envelope 时也做尺寸检查。
65
+
66
+ 没有 envelope 时旧流程仍可在原 Wiki 证据范围 verified,但 selected_models.dimension_check 始终显式为 unknown(PRODUCT_ENVELOPE_MISSING;槽位有 fit_envelope 时除外),不能宣传尺寸检查通过。clear 也只表示单件粗筛;device_tested 仍 false。hardware.finalize 仍只返回 proposed/needs_confirmation 的提案,不创建硬件正式资源。
65
67
 
66
68
  每个型号额外携带可信候选 title 派生的 name、dimensions_mm、dimension_basis、dimension_geometry、dimension_source 和 dimension_check;interfaces 只在候选存在时透传。bom_item_id 为 `selection-line-<24hex>`,以 slot_id/component_id 派生的稳定选型行身份,不冒称数据库 BOM 主键,不使用模型提供的 ID。
67
69
 
68
70
  有 envelope 时输出增加可选 occupancy:advisory=true、method、estimated_volume_mm3、unknown_lines、containers(limit_id/cavity_volume_mm3/ratio)。只统计 selected_models 的实际数量,按 bom_item_id 去重,不再次加入 coverage_ledger 的整机内部覆盖。它是包围体积估算,非装箱证明;unknown 基础的占位尺寸不计入体积,仅保留 unknown_lines;全未知时不输出容器密度。部分未知时体积/比例只含已知或显式估算行,不代表整体密度。浮点溢出/下溢等不可表示的几何量保持 unknown,无法表示的体积或比例省略,不序列化 NaN/Infinity;没有隐含硬阈值。工具函数只在调用方显式提供 occupancy_warning_ratio 策略时给 warning,默认没有告警阈值。接入方须接受这一可选加法,但不得将 occupancy 或 ratio 作为通过/拒绝权威。
71
+
72
+ ## 电气兼容与兼容报告
73
+
74
+ 多型号选型由 `electrical.mjs` 按星形拓扑做确定性电气检查:选中的唯一主控(`compute-control/controller-board|controller-module`)为中心,`power-energy` 件给主控供电,主控电压轨与已选电源给外设供电,外设所需总线由主控提供。没有主控或主控不止一个时拓扑记为 not_star,全部未知。
75
+
76
+ 只有 `electrical.basis` 为 measured / datasheet、且通过 `electrical_source` 源绑定的记录能给出确定结论。数据没记录不等于不需要或没有:缺失一律记 unknown,unknown 永不升级为 pass;星形假设本身不是证明,因此 v1 不产生 fail,不以电气检查硬拒绝(VALIDATION)。
77
+
78
+ | 结果 | 含义 | 门禁 |
79
+ | --- | --- | --- |
80
+ | pass | 全部相关件可信,且每条规则都有完整数据并通过:电压在输入范围内、电流有记录且不超额、逻辑电平有记录且一致(或由可信电平转换件化解)、总线足够 | power / interfaces 视为已验证,不再要求逐件原文引用 |
81
+ | gap | 可信数据下没有电压轨落在外设或主控的输入范围内(`NO_SUPPLY_RAIL_FOR_INPUT`) | 记 `ELECTRICAL_SUPPLY_GAP`,返回 needs_confirmation;须补电源或稳压件,不拒绝外设 |
82
+ | risk | 电压只部分重叠(`RAIL_PARTIALLY_OUTSIDE_INPUT`)、连接器不同(`CONNECTOR_MISMATCH`)、电压轨超载(`RAIL_CURRENT_EXCEEDED`)、逻辑电平不同且未选电平转换件(`LOGIC_LEVEL_MISMATCH`)、同一 SPI 上外设多于主控 SPI 数(`SPI_CHIP_SELECT_UNVERIFIED`)、主控缺该总线类型或数量不足(`BUS_TYPE_MISSING` / `BUS_COUNT_SHORT`) | 不确定性判定,回到原文引用路径;总线类型缺失或数量不足另记 `ELECTRICAL_BUS_UNCONFIRMED` 并返回 needs_confirmation,引用也不能覆盖 |
83
+ | unknown | 数据缺失、未源绑定或非星形:`UNTRUSTED_OR_MISSING_DATA`、`UNTRUSTED_SOURCE_MAY_SUPPLY`、`NO_DECLARED_INPUT`(可信件 power_inputs 为空)、`CURRENT_UNRECORDED`(电压轨或任一受电方缺 max_current_a)、`CONTROLLER_SUPPLY_OUTSIDE_SELECTION`(未选电源件,主控供电在选型之外)、`LOGIC_LEVEL_UNRECORDED`(任一侧缺 logic_level_v)、`LEVEL_SHIFTER_UNTRUSTED`(电平不符且所选转换件不可信)、`CONTROLLER_BUSES_UNRECORDED`(主控未记录任何 controller 角色总线) | 回到原文引用路径 |
84
+
85
+ 电流按以下保守、可解释的方式检查(v1 没有显式的供电分配):输入的 `max_current_a` 指该输入端的最大取用电流(含经本件输出转供的负载),输出的 `max_current_a` 指该电压轨的最大供给电流。每个受电方从电压匹配的可信电压轨中选记录电流最大的一条(无电流记录的排最后,同等时按出现顺序);一条电压轨的负载是分配到它的全部受电方 `max_current_a × quantity` 之和,数量大于 1 的电源件的同一电压轨只按一份供给计。已记录负载之和超过供给即 `RAIL_CURRENT_EXCEEDED`;该电压轨或其任一受电方缺电流记录即 `CURRENT_UNRECORDED`。这种分配可能把本可另行分摊的负载判为风险,只会多要求确认,不会误判通过。
86
+
87
+ v1 的已知局限:不检查 I2C 地址冲突;SPI 只按“外设数多于主控 SPI 数”提示片选风险,不核对片选引脚数;电平转换件、扩展板与驱动板自身的供电和下游总线不建模(扩展板后面挂的外设会被算到主控头上,所以总线不足只记风险)。
88
+
89
+ mechanical、thermal、software 只作提示,不进入 unknowns,也不阻止 verified;结构可行性由尺寸粗筛和 CAD 检查负责。任何维度只要模型明确给出 status=incompatible,都记 `COMPATIBILITY_INCOMPATIBLE` 并阻塞。
90
+
91
+ 多型号时输出增加可选根字段 `compatibility_report`:`method`、`dimensions`(每维 gate=deterministic|evidence|unverified|advisory、model_status、model_verified,power / interfaces 另有 electrical 结论)和 `electrical`(拓扑、主控与逐条检查;供电检查另带 `current`:status=within|exceeded|unrecorded、draw_a、rail_load_a、capacity_a)。它是选型依据的说明,不代表实测或制造可行;接入方须显式接收这一可选字段。所选型号原样携带候选的 `electrical` 与 `electrical_source`,不采用模型自填。
92
+
93
+ 多型号时若有提示,输出再增加可选根字段 `advisories`(没有提示时省略):每个 gate=advisory 且模型未给出有效 verified 引用的维度一条 `{code:"COMPATIBILITY_ADVISORY_UNVERIFIED", dimension, model_status}`;power / interfaces 电气结论为 risk、却凭模型引用以 gate=evidence 通过时一条 `{code:"ELECTRICAL_RISK_OVERRIDDEN_BY_EVIDENCE", dimension, electrical:"risk"}`。advisories 只供展示与复核,不影响 status,也不进入 unknowns;接入方须显式接收这一可选字段。
94
+
95
+ 已保存的选型结果按保存时规则原样只读;旧运行重新执行 hardware.select 时按上述新规则重算,可能由 verified 变为 needs_confirmation,也可能由原先的 VALIDATION 拒绝变为 needs_confirmation。
@@ -3,6 +3,7 @@ import { evaluateDimensions, validateRequirementEnvelopes } from "../../abelien-
3
3
  import { createHash } from "node:crypto";
4
4
  import { mergeHardwareWeb } from "../../abelien-hardware/scripts/web-merge.mjs";
5
5
  import { finalizeHardwareSelection } from "../../abelien-hardware/scripts/finalize-selection.mjs";
6
+ import { evaluateElectrical } from "../../abelien-hardware/scripts/electrical.mjs";
6
7
  import { canonical, digest, selectionRequestDigest, validateSelectionSlots } from "../../abelien-hardware/scripts/selection-request.mjs";
7
8
  const record = value => value && typeof value === "object" && !Array.isArray(value);
8
9
  const text = value => typeof value === "string" && value.trim().length > 0;
@@ -57,7 +58,7 @@ export async function selectHardware({ input, readResource, readBlob }) {
57
58
  if (!candidate || canonical(candidate.category_path) !== canonical(slot.category_path)
58
59
  || !text(candidate.content) || !/^[a-f0-9]{64}$/.test(candidate.wiki?.sha256 ?? "")) invalid("candidate needs complete matching Wiki provenance");
59
60
  if (createHash("sha256").update(candidate.content).digest("hex") !== candidate.wiki.sha256) invalid("candidate content does not match frozen Wiki SHA-256");
60
- const dimensionCheck = evaluateDimensions(candidate, requirements);
61
+ const dimensionCheck = evaluateDimensions(candidate, requirements, slot);
61
62
  model.dimension_check = dimensionCheck;
62
63
  if (dimensionCheck.status === "incompatible") invalid("selected candidate is dimensionally incompatible");
63
64
  // Only a user hard limit with trusted sizes asks the user to decide; unknown and other risk stay advisory on dimension_check.
@@ -101,12 +102,36 @@ export async function selectHardware({ input, readResource, readBlob }) {
101
102
  if (recall.retrieval_mode !== "category_index" || recall.unknowns?.length) add("RECALL_INCOMPLETE", { details: recall.unknowns ?? [] });
102
103
  const compatibility = proposal.compatibility_checks ?? [];
103
104
  if (!Array.isArray(compatibility)) invalid("compatibility_checks must be an array");
104
- if (models.size > 1) for (const dimension of ["power", "interfaces", "mechanical", "thermal", "software"]) {
105
- const checks = compatibility.filter(item => item?.dimension === dimension);
106
- if (checks.length !== 1 || checks[0].status !== "verified" || !text(checks[0].reason)
107
- || !validQuotes(checks[0].evidence, candidates)
108
- || !candidates.every(candidate => checks[0].evidence.some(item => item.component_id === candidate.component_id)))
109
- add("COMPATIBILITY_UNVERIFIED", { dimension, assessments: checks });
105
+ // Power/interfaces: trusted deterministic pass, or quoted model evidence. Mechanical/thermal/software are advisory
106
+ // (size screening and CAD own mechanical fit): unquoted ones are listed in advisories, not unknowns, so they never block verified.
107
+ // An explicit model "incompatible" still blocks every dimension.
108
+ let compatibilityReport;
109
+ const advisories = [];
110
+ if (models.size > 1) {
111
+ const electrical = evaluateElectrical(normalized.selected_models);
112
+ const gaps = electrical.checks.filter(check => check.status === "gap");
113
+ if (gaps.length) add("ELECTRICAL_SUPPLY_GAP", { checks: gaps });
114
+ // Bus shortfalls under the star assumption are not proof (expansion and driver boards are not modelled): confirm, never reject.
115
+ const buses = electrical.checks.filter(check => check.rule === "bus_availability" && check.status === "risk");
116
+ if (buses.length) add("ELECTRICAL_BUS_UNCONFIRMED", { checks: buses });
117
+ const dimensions = {};
118
+ for (const dimension of ["power", "interfaces", "mechanical", "thermal", "software"]) {
119
+ const checks = compatibility.filter(item => item?.dimension === dimension);
120
+ const modelVerified = checks.length === 1 && checks[0].status === "verified" && text(checks[0].reason)
121
+ && validQuotes(checks[0].evidence, candidates)
122
+ && candidates.every(candidate => checks[0].evidence.some(item => item.component_id === candidate.component_id));
123
+ const deterministic = electrical.dimensions[dimension];
124
+ const gate = ["mechanical", "thermal", "software"].includes(dimension) ? "advisory"
125
+ : deterministic === "pass" ? "deterministic" : modelVerified ? "evidence" : "unverified";
126
+ const modelStatus = checks.length === 1 ? String(checks[0].status) : checks.length ? "ambiguous" : "missing";
127
+ if (gate === "unverified") add("COMPATIBILITY_UNVERIFIED", { dimension, assessments: checks, electrical: deterministic });
128
+ if (checks.some(item => item?.status === "incompatible")) add("COMPATIBILITY_INCOMPATIBLE", { dimension, assessments: checks });
129
+ if (gate === "advisory" && !modelVerified) advisories.push({ code: "COMPATIBILITY_ADVISORY_UNVERIFIED", dimension, model_status: modelStatus });
130
+ if (gate === "evidence" && deterministic === "risk") advisories.push({ code: "ELECTRICAL_RISK_OVERRIDDEN_BY_EVIDENCE", dimension, electrical: "risk" });
131
+ dimensions[dimension] = { gate, model_status: modelStatus,
132
+ model_verified: modelVerified, ...(deterministic !== undefined ? { electrical: deterministic } : {}) };
133
+ }
134
+ compatibilityReport = { method: "compatibility-gate-v1", dimensions, electrical };
110
135
  }
111
136
  const coverage = slots.map(slot => ({ slot_id: slot.slot_id, requested_quantity: slot.quantity,
112
137
  selected_quantity: models.get(slot.slot_id)?.quantity ?? 0,
@@ -123,6 +148,8 @@ export async function selectHardware({ input, readResource, readBlob }) {
123
148
  await checkMergedSource();
124
149
  const hash = digest({ input, source_digest: digest(source.body) });
125
150
  return { ...normalized, id: `selection-${hash.slice(0, 16)}`, revision: `r-${hash.slice(0, 12)}`,
126
- status, coverage_ledger: coverage, remaining_slots: remaining, unknowns };
151
+ status, coverage_ledger: coverage, remaining_slots: remaining, unknowns,
152
+ ...(compatibilityReport ? { compatibility_report: compatibilityReport } : {}),
153
+ ...(advisories.length ? { advisories } : {}) };
127
154
  }
128
155
  export const run = selectHardware;