abelien-harness 0.3.0 → 0.5.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 (62) hide show
  1. package/.env.example +3 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/skills/abelien-3dgen/SKILL.md +4 -2
  5. package/skills/abelien-3dgen/providers/tripo.json +64 -1
  6. package/skills/abelien-3dgen/references/engineering-lowpoly-qualification.md +20 -0
  7. package/skills/abelien-3dgen/references/host-api.md +128 -0
  8. package/skills/abelien-3dgen/references/provider-profile.md +4 -0
  9. package/skills/abelien-appearance/SKILL.md +2 -0
  10. package/skills/abelien-appearance/references/local-component-edit.md +31 -0
  11. package/skills/abelien-clarify/SKILL.md +8 -3
  12. package/skills/abelien-clarify/references/examples.md +9 -0
  13. package/skills/abelien-clarify/scripts/check_result.py +53 -1
  14. package/skills/abelien-clarify/scripts/test_check_result.py +40 -0
  15. package/skills/abelien-guide/SKILL.md +2 -0
  16. package/skills/abelien-guide/references/shared-response-rules.md +2 -2
  17. package/skills/abelien-hardware/SKILL.md +12 -2
  18. package/skills/abelien-hardware/domain.json +4 -4
  19. package/skills/abelien-hardware/references/search-index-format.md +41 -2
  20. package/skills/abelien-hardware/references/search-index-release.md +12 -1
  21. package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
  22. package/skills/abelien-hardware/references/web-evidence-contract.md +2 -2
  23. package/skills/abelien-hardware/scripts/build-search-index.mjs +50 -6
  24. package/skills/abelien-hardware/scripts/dimensions.mjs +184 -0
  25. package/skills/abelien-hardware/scripts/electrical.mjs +125 -0
  26. package/skills/abelien-hardware/scripts/finalize-selection.mjs +15 -0
  27. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +10 -6
  28. package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
  29. package/skills/abelien-hardware/scripts/search-index-release.mjs +45 -19
  30. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +40 -20
  31. package/skills/abelien-hardware/scripts/volc-web-search.mjs +6 -1
  32. package/skills/abelien-hardware/scripts/web-common.mjs +4 -1
  33. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +8 -2
  34. package/skills/abelien-hardware/scripts/web-search-plan.mjs +16 -1
  35. package/skills/abelien-hardware/scripts/web-search.mjs +4 -2
  36. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  37. package/skills/abelien-hardware-selection/domain.json +2 -2
  38. package/skills/abelien-hardware-selection/references/selection-service-contract.md +32 -2
  39. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -7
  40. package/skills/abelien-harness/references/workflow-spec.md +15 -2
  41. package/skills/abelien-harness/scripts/generate3d-host.mjs +398 -0
  42. package/skills/abelien-harness/scripts/generate3d.mjs +54 -15
  43. package/skills/abelien-harness/scripts/receipt.mjs +8 -2
  44. package/skills/abelien-harness/scripts/studio.mjs +18 -10
  45. package/skills/abelien-structure/SKILL.md +3 -1
  46. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  47. package/skills/abelien-structure/scripts/run_stage.py +8 -1
  48. package/skills/abelien-workspace/SKILL.md +2 -0
  49. package/skills/abelien-workspace/domain.json +1 -1
  50. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  51. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  52. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  53. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  54. package/skills/abelien-workspace/workflows/definitions/a6e8254e1839d94530ae243cba779e423953b7169cbce338f0503d748d44800b.json +175 -0
  55. package/skills/abelien-workspace/workflows/definitions/b37f3b47e1e8bd1208859de57456152b149416e21949297b22bba937c51a5670.json +149 -0
  56. package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +229 -0
  57. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  58. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  59. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  60. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -3
  61. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -3
  62. package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -3
@@ -1,4 +1,7 @@
1
1
  import crypto from "node:crypto";
2
+ import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
3
+ import { electricalFields, evaluateElectrical } from "./electrical.mjs";
4
+ import { selectionRequestDigest } from "./selection-request.mjs";
2
5
 
3
6
  function error(message, details) {
4
7
  const out = new Error(message);
@@ -23,6 +26,7 @@ export async function finalizeHardwareSelection({ input }) {
23
26
  if (!recall || recall.status !== "completed" || !recall.source_snapshot) throw error("completed knowledge_recall is required");
24
27
  if (!["proposed", "verified", "needs_confirmation"].includes(proposal.status)) throw error("selection_proposal.status is not recognized");
25
28
  if (!["adapt_system", "assemble_new"].includes(proposal.plan_mode)) throw error("selection_proposal.plan_mode is not recognized");
29
+ if (recall.request_digest && recall.request_digest !== selectionRequestDigest(input)) throw error("recall does not match selection requirements and slots");
26
30
  const proposedModels = requireArray(proposal.selected_models, "selected_models");
27
31
  const coverageLedger = requireArray(proposal.coverage_ledger, "coverage_ledger");
28
32
  const remainingSlots = requireArray(proposal.remaining_slots, "remaining_slots");
@@ -71,7 +75,14 @@ export async function finalizeHardwareSelection({ input }) {
71
75
  slot_id: selected.slot_id, component_id: selected.component_id,
72
76
  });
73
77
  }
78
+ const dimensionCheck = evaluateDimensions(candidate, input.requirements);
79
+ if (dimensionCheck.status === "incompatible") throw error("selected component is dimensionally incompatible; choose a surviving candidate", { slot_id: selected.slot_id, component_id: selected.component_id, dimension_check: dimensionCheck });
74
80
  return {
81
+ name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
82
+ // This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
83
+ bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
84
+ ...dimensionFields(candidate), dimension_check: dimensionCheck, ...electricalFields(candidate),
85
+ ...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
75
86
  slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
76
87
  selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
77
88
  ...(selected.constraint_checks !== undefined ? { constraint_checks: selected.constraint_checks } : {}),
@@ -84,6 +95,9 @@ export async function finalizeHardwareSelection({ input }) {
84
95
  ...(candidate.source_type === "external_web" ? { source_type: candidate.source_type, source: candidate.source, geometry: candidate.geometry, CAD_available: false, cad_available: false, engineering_verified: false } : {}),
85
96
  };
86
97
  });
98
+ // Trusted data proving a part cannot connect means one side must be replaced; a missing supply rail is a gap for select to report.
99
+ const failed = selectedModels.length > 1 ? evaluateElectrical(selectedModels).checks.filter(check => check.status === "fail") : [];
100
+ if (failed.length) throw error("selected components are electrically incompatible; replace one side of each failed check", { electrical_checks: failed });
87
101
  const stable = {
88
102
  requirements: input.requirements ?? {}, slots: input.slots ?? [], source_snapshot: recall.source_snapshot,
89
103
  proposal: { ...proposal, selected_models: selectedModels },
@@ -97,6 +111,7 @@ export async function finalizeHardwareSelection({ input }) {
97
111
  revision: `r-${hash.slice(0, 12)}`,
98
112
  plan_mode: proposal.plan_mode,
99
113
  selected_models: selectedModels,
114
+ ...(input.requirements?.limits?.some(row => row?.envelope) ? { occupancy: assemblyOccupancy(selectedModels, input.requirements, input.policy) } : {}),
100
115
  coverage_ledger: coverageLedger,
101
116
  remaining_slots: remainingSlots,
102
117
  unknowns,
@@ -2,7 +2,7 @@ import { digest, selectionRequestDigest } from "./selection-request.mjs";
2
2
  import { failure, text } from "./web-common.mjs";
3
3
  import { sha256, normalizePublicSourceUrl, pageText, publicAddress, publicReadFailureCodes } from "./public-page.mjs";
4
4
  import { intactReceipt, successfulReceipt } from "./web-receipt.mjs";
5
- import { volcQueryRequest } from "./volc-web-search.mjs";
5
+ import { volcQueryRequest, legacyVolcQueryRequest } from "./volc-web-search.mjs";
6
6
  import { isIP } from "node:net";
7
7
  export const chainProtocol = "prismer-volc-source-v2", chainPolicy = "prismer_then_volc";
8
8
  export const receiptFields = ["raw", "raw_sha256", "response", "response_sha256", "request_id", "received_at", "http_status"];
@@ -13,8 +13,11 @@ export const same = (left, right) => digest({ value: left }) === digest({ value:
13
13
  export const selectedReceipt = group => group?.attempts?.find(attempt => attempt.id === group.selected_attempt_id) ?? null;
14
14
  export const queryResults = attempt => attempt?.provider === "volcengine" ? attempt.response?.Result?.WebResults?.map(result => ({ ...result, url: result?.Url ?? null })) : attempt?.response?.results;
15
15
  export const missingKey = attempt => attempt?.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "missing_key" && attempt.error_code === "HARDWARE_WEB_CONFIG" && attempt.raw == null && attempt.response == null && attempt.http_status == null;
16
+ // A Prismer attempt skipped by the trusted host switch is recorded unsent with no receipt;
17
+ // it authorizes the same group's Volc query or direct source read.
18
+ export const prismerDisabled = attempt => attempt?.provider === "prismer" && attempt.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "prismer_disabled" && attempt.error_code == null && attempt.raw == null && attempt.response == null && attempt.http_status == null;
16
19
  export const knownDenial = attempt => attempt?.provider === "prismer" && attempt.status === "denied" && attempt.dispatch_state === "sent" && [401, 403, 402, 429].includes(attempt.http_status) && intactReceipt(attempt);
17
- export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
20
+ export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || prismerDisabled(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
18
21
  export function expectedSelection(group, query) {
19
22
  const primary = group.attempts?.[0], fallback = group.attempts?.[1];
20
23
  if (!primary) return null;
@@ -67,13 +70,14 @@ function validateGroup(group, item, page, partial) {
67
70
  const operation = page ? sourceOperation(page.source_url) : null;
68
71
  for (const [index, attempt] of attempts.entries()) {
69
72
  const provider = index === 0 ? "prismer" : query ? "volcengine" : "direct_https", kind = query ? "query" : index === 0 ? operation.kind : "direct_page";
70
- const request = query ? index === 0 ? primaryQuery(item) : volcQueryRequest(item) : index === 0 ? operation.request : { url: page.source_url, method: "GET" };
71
- if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || attempt.id !== operationId(group.id, provider, kind, request) || !same(attempt.request, request)) return false;
73
+ // Volc receipts saved before the Count-5 default keep their frozen Count-10 request shape.
74
+ const requests = query ? index === 0 ? [primaryQuery(item)] : [volcQueryRequest(item), legacyVolcQueryRequest(item)] : [index === 0 ? operation.request : { url: page.source_url, method: "GET" }];
75
+ if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || !requests.some(request => attempt.id === operationId(group.id, provider, kind, request) && same(attempt.request, request))) return false;
72
76
  if (index === 1 && !permitsFallback(attempts[0], query)) return false;
73
77
  if (["in_progress", "unknown", "failed"].includes(attempt.status)) return false;
74
78
  if (provider === "direct_https") { if (!validDirect(attempt) || (attempt.dispatch_state === "not_sent" && operation.kind !== "parse") || (attempt.public_read_failure && operation.kind === "parse")) return false; continue; }
75
79
  if (Object.hasOwn(attempt, "public_read_failure")) return false;
76
- if (missingKey(attempt) || knownDenial(attempt)) continue;
80
+ if (missingKey(attempt) || knownDenial(attempt) || prismerDisabled(attempt)) continue;
77
81
  if (attempt.dispatch_state !== "sent" || !["completed", "pending"].includes(attempt.status) || !successfulReceipt(attempt)) return false;
78
82
  if (query && !Array.isArray(queryResults(attempt))) return false;
79
83
  if (attempt.status === "pending" && !(kind === "parse" && text(attempt.task_id) && attempt.task_id === attempt.response.taskId)) return false;
@@ -81,7 +85,7 @@ function validateGroup(group, item, page, partial) {
81
85
  if (page && (!Array.isArray(attempt.observations) || !attempt.observations.every(value => ["status", "result"].includes(value.kind) && (value.error_code ? ((value.raw === undefined && value.response === undefined) || intactReceipt(value)) : successfulReceipt(value) && value.response.taskId === attempt.task_id)))) return false;
82
86
  }
83
87
  const expected = expectedSelection(group, query), selected = selectedReceipt(group);
84
- if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
88
+ if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || prismerDisabled(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
85
89
  if (!selected || !expected || selected.id !== expected.id) return false;
86
90
  return group.selected_provider === selected.provider && (query ? group.status === "completed" && receiptFields.every(key => same(group[key], selected[key])) : same(page.attempt, selected));
87
91
  }
@@ -8,7 +8,7 @@ import { publishHardwareSearchIndex } from "./search-index-release.mjs";
8
8
  function parseArgs(argv) {
9
9
  const flags = {};
10
10
  const booleans = new Set(["--dry-run", "--skip-smoke", "--bootstrap-manifest"]);
11
- const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency"]);
11
+ const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency", "--component-snapshot"]);
12
12
  for (let i = 0; i < argv.length; i++) {
13
13
  const name = argv[i];
14
14
  if (booleans.has(name)) flags[name.slice(2)] = true;
@@ -30,6 +30,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
30
30
  sourceDir: path.resolve(cwd, flags.source),
31
31
  outputDir: flags.output ? path.resolve(cwd, flags.output) : undefined,
32
32
  releaseId: flags["release-id"],
33
+ componentSnapshot: flags["component-snapshot"] ? path.resolve(cwd, flags["component-snapshot"]) : undefined,
33
34
  env,
34
35
  fetchImpl: deps.fetchImpl || fetch,
35
36
  dryRun: Boolean(flags["dry-run"]),
@@ -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);
@@ -69,6 +75,7 @@ export function validateLocalRelease(outputDir) {
69
75
  if (index.source_manifest_sha256 !== catalog.source_manifest?.sha256) {
70
76
  throw releaseError("INDEX_RELEASE_INVALID", `index Manifest digest mismatch: ${descriptor.index_key}`);
71
77
  }
78
+ if (index.records.some(record => !/^[a-f0-9]{64}$/.test(record.sha256 ?? ""))) throw releaseError("INDEX_RELEASE_INVALID", "every Wiki needs a content SHA-256; rebuild legacy indexes");
72
79
  documents += index.records.length;
73
80
  files.push({ key: descriptor.index_key, file, bytes, sha256: descriptor.sha256 });
74
81
  }
@@ -132,10 +139,7 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
132
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 });
133
140
  });
134
141
  const key = `${config.prefix}${catalog.source_manifest.object_key || "_manifest.json"}`;
135
- await putTosObject(config, key, manifestBytes, {
136
- fetchImpl,
137
- headers: { "content-type": "application/json", "if-none-match": "*", "x-amz-meta-sha256": sha256(manifestBytes) },
138
- });
142
+ await putImmutable(config, key, manifestBytes, fetchImpl);
139
143
  const uploaded = await getTosObject(config, key, { fetchImpl });
140
144
  if (sha256(uploaded.bytes) !== catalog.source_manifest.sha256) {
141
145
  throw releaseError("SOURCE_MANIFEST_MISMATCH", "uploaded Wiki Manifest SHA-256 mismatch", { key });
@@ -143,6 +147,15 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
143
147
  return { key, etag: uploaded.etag, sha256: catalog.source_manifest.sha256, bootstrapped: true, verified_wiki_objects: manifest.length };
144
148
  }
145
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
+ // 并发写同一键只会写入同一份字节;已存在但字节不同即冲突失败,不覆盖。
146
159
  async function putImmutable(config, key, bytes, fetchImpl) {
147
160
  const expected = sha256(bytes);
148
161
  const existing = await getTosObject(config, key, { fetchImpl, optional: true });
@@ -151,14 +164,10 @@ async function putImmutable(config, key, bytes, fetchImpl) {
151
164
  if (actual !== expected) throw releaseError("IMMUTABLE_OBJECT_CONFLICT", "release object already exists with different bytes", { key, expected_sha256: expected, actual_sha256: actual });
152
165
  return { key, sha256: expected, reused: true };
153
166
  }
154
- try {
155
- await putTosObject(config, key, bytes, {
156
- fetchImpl,
157
- headers: { "content-type": key.endsWith(".gz") ? "application/gzip" : "application/json", "if-none-match": "*" },
158
- });
159
- } catch (cause) {
160
- if (cause.code !== "TOS_HTTP_STATUS" || cause.details?.status !== 412) throw cause;
161
- }
167
+ await putTosObject(config, key, bytes, {
168
+ fetchImpl,
169
+ headers: { "content-type": objectContentType(key), "x-amz-meta-sha256": expected },
170
+ });
162
171
  const uploaded = await getTosObject(config, key, { fetchImpl });
163
172
  const actual = sha256(uploaded.bytes);
164
173
  if (actual !== expected) throw releaseError("INDEX_UPLOAD_VERIFY_FAILED", "uploaded release object SHA-256 mismatch", { key, expected_sha256: expected, actual_sha256: actual });
@@ -242,12 +251,12 @@ export async function smokePublishedIndex({ env, fetchImpl = fetch, categoryPath
242
251
  };
243
252
  }
244
253
 
245
- export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
254
+ export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
246
255
  if (!sourceDir) throw releaseError("USAGE", "sourceDir is required");
247
256
  const temporary = !outputDir;
248
257
  const output = outputDir ? path.resolve(outputDir) : fs.mkdtempSync(path.join(os.tmpdir(), "abelien-hardware-index-"));
249
258
  try {
250
- const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId });
259
+ const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId, componentSnapshot });
251
260
  const local = validateLocalRelease(output);
252
261
  if (dryRun) return {
253
262
  status: "validated",
@@ -262,6 +271,18 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
262
271
  };
263
272
 
264
273
  const config = createTosConfig(env);
274
+ if (built.generated_source) {
275
+ const manifestBytes = fs.readFileSync(path.join(built.generated_source, "_manifest.json"));
276
+ const entries = JSON.parse(manifestBytes);
277
+ const limit = Number(concurrency || env?.TOS_PUBLISH_CONCURRENCY || 6);
278
+ await mapConcurrent(entries, Number.isFinite(limit) ? limit : 6, async entry => {
279
+ const bytes = fs.readFileSync(safeFile(built.generated_source, entry.path));
280
+ if (sha256(bytes) !== entry.sha256) throw releaseError("SOURCE_WIKI_MISMATCH", "generated Wiki changed after build");
281
+ await putImmutable(config, `${config.prefix}${entry.path}`, bytes, fetchImpl);
282
+ });
283
+ await putImmutable(config, `${config.prefix}${local.catalog.component_snapshot.object_key}`, fs.readFileSync(path.join(built.generated_source, "_component-dimensions.json")), fetchImpl);
284
+ await putImmutable(config, `${config.prefix}${local.catalog.source_manifest.object_key}`, manifestBytes, fetchImpl);
285
+ }
265
286
  let manifest = await requireMatchingManifest(config, local.catalog, fetchImpl, { optional: true });
266
287
  if (!manifest && !bootstrapManifest) {
267
288
  throw releaseError("SOURCE_MANIFEST_MISSING", "TOS Wiki Manifest is missing; rerun with --bootstrap-manifest to verify every remote Wiki object before creating it", {
@@ -277,11 +298,15 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
277
298
  const uploaded = await mapConcurrent(local.files, Number.isFinite(limit) ? limit : 6, item =>
278
299
  putImmutable(config, `${config.prefix}${item.key}`, item.bytes, fetchImpl));
279
300
 
280
- const catalogHeaders = {
281
- "content-type": "application/json",
282
- ...(previousCatalog ? { "if-match": `"${previousCatalog.etag}"` } : { "if-none-match": "*" }),
283
- };
284
- 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" } });
285
310
  const verified = await verifyPublishedIndex({ env, fetchImpl, concurrency: limit });
286
311
  if (verified.release_id !== local.catalog.release_id) {
287
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 });
@@ -295,6 +320,7 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
295
320
  uploaded_shards: uploaded.filter(item => !item.reused).length,
296
321
  reused_shards: uploaded.filter(item => item.reused).length,
297
322
  catalog_key: catalogKey,
323
+ previous_catalog: previousCatalog ? { sha256: previousSha256, release_id: previousReleaseId(previousCatalog) } : null,
298
324
  verified,
299
325
  smoke: smokeResult,
300
326
  };
@@ -1,4 +1,6 @@
1
1
  import zlib from "node:zlib";
2
+ import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateRequirementEnvelopes } from "./dimensions.mjs";
3
+ import { electricalFields } from "./electrical.mjs";
2
4
  import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
3
5
  import { createTosConfig, getTosObject, sha256 } from "./tos-object-store.mjs";
4
6
 
@@ -81,11 +83,17 @@ export function parseHardwareWiki(content) {
81
83
  "schema", "type", "title", "description", "manufacturer", "model", "aliases", "resource_urn", "record_id",
82
84
  "record_version", "status", "category_path", "category_leaf", "primary_function", "integration_level",
83
85
  "hardware_form", "component_kind", "functional_capabilities", "capability_coverage", "source_url", "source_updated_at",
86
+ "dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces", "electrical", "electrical_source",
84
87
  ];
85
88
  const meta = {};
86
89
  for (const key of wanted) {
87
90
  const line = frontMatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
88
- if (line) meta[key] = scalar(line[1]);
91
+ if (line) {
92
+ if (["dimension_source", "dimensions_mm", "electrical", "electrical_source"].includes(key)) {
93
+ try { meta[key] = JSON.parse(line[1]); }
94
+ catch { throw error("WIKI_INVALID", `${key} must be generated inline JSON`); }
95
+ } else meta[key] = scalar(line[1]);
96
+ }
89
97
  }
90
98
  meta.selection_profile = {
91
99
  suitable_for: nestedInline(frontMatter, "selection_profile", "suitable_for") ?? [],
@@ -199,6 +207,8 @@ function parseCategoryIndex(object, descriptor, key) {
199
207
  }
200
208
  if (recordIds.has(record.record_id)) throw error("WIKI_SEARCH_INDEX_INVALID", "category index contains duplicate record_id", { key, record_id: record.record_id });
201
209
  recordIds.add(record.record_id);
210
+ if (!/^[a-f0-9]{64}$/.test(record.sha256 ?? "")) throw error("WIKI_SEARCH_INDEX_INVALID", "category index requires every Wiki content SHA-256; rebuild legacy indexes", { key, record_id: record.record_id });
211
+ dimensionFields(record);
202
212
  record.path = safeRelativeObjectKey(record.path, "Wiki path");
203
213
  }
204
214
  return index;
@@ -218,6 +228,7 @@ async function mapConcurrent(items, concurrency, mapper) {
218
228
  }
219
229
 
220
230
  function validateInput(input) {
231
+ validateRequirementEnvelopes(input?.requirements);
221
232
  if (!input || typeof input !== "object" || Array.isArray(input)) throw error("VALIDATION", "hardware search input must be an object");
222
233
  if (!Array.isArray(input.slots) || input.slots.length === 0) throw error("VALIDATION", "hardware search requires non-empty slots");
223
234
  for (const slot of input.slots) {
@@ -258,6 +269,10 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
258
269
  manufacturer: meta.manufacturer == null ? "Unspecified" : String(meta.manufacturer),
259
270
  model: meta.model == null ? meta.model : String(meta.model),
260
271
  title: meta.title,
272
+ name: meta.title,
273
+ ...dimensionFields(meta),
274
+ ...electricalFields(meta),
275
+ ...(meta.interfaces !== undefined ? { interfaces: meta.interfaces } : {}),
261
276
  category_path: meta.category_path,
262
277
  integration_level: meta.integration_level,
263
278
  hardware_form: meta.hardware_form,
@@ -279,7 +294,7 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
279
294
  };
280
295
  }
281
296
 
282
- function createWikiLoader(config, fetchImpl) {
297
+ function createWikiLoader(config, fetchImpl, { legacy = false } = {}) {
283
298
  const cache = new Map();
284
299
  return entry => {
285
300
  if (!cache.has(entry.path)) {
@@ -290,9 +305,10 @@ function createWikiLoader(config, fetchImpl) {
290
305
  path: entry.path, indexed_record_id: entry.record_id, wiki_record_id: parsed.meta.record_id,
291
306
  });
292
307
  }
293
- if (entry.sha256 && sha256(parsed.content) !== entry.sha256) {
308
+ if (!/^[a-f0-9]{64}$/.test(entry.sha256 ?? "") || sha256(parsed.content) !== entry.sha256) {
294
309
  throw error("WIKI_SEARCH_INDEX_MISMATCH", "Wiki content SHA-256 does not match its search index", { path: entry.path });
295
310
  }
311
+ if (!legacy && !sameDimensionMetadata(parsed.meta, entry)) throw error("WIKI_SEARCH_INDEX_MISMATCH", "Wiki dimension metadata does not match its search index", { path: entry.path });
296
312
  return { ...parsed, etag: object.etag, lastModified: object.lastModified, entry };
297
313
  }));
298
314
  }
@@ -300,7 +316,7 @@ function createWikiLoader(config, fetchImpl) {
300
316
  };
301
317
  }
302
318
 
303
- async function searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, includeContent }) {
319
+ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, maxFetch, includeContent }) {
304
320
  const catalogKey = `${config.prefix}_catalog.json`;
305
321
  const { categories } = parseCatalog(catalogObject, catalogKey);
306
322
  const indexCache = new Map();
@@ -314,7 +330,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
314
330
  if (!indexCache.has(descriptor.index_key)) {
315
331
  indexCache.set(descriptor.index_key, getObject(config, `${config.prefix}${descriptor.index_key}`, fetchImpl).then(object => {
316
332
  const index = parseCategoryIndex(object, descriptor, `${config.prefix}${descriptor.index_key}`);
317
- shardSnapshots.push(`${descriptor.index_key}:${object.etag}`);
333
+ shardSnapshots.push(`${descriptor.index_key}:${object.etag}:${object.raw_sha256}`);
318
334
  return index;
319
335
  }));
320
336
  }
@@ -333,23 +349,26 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
333
349
  }
334
350
  const index = await loadIndex(descriptor);
335
351
  const semanticQuery = semanticQueryFor(input, slot);
336
- const rankedIndex = index.records.map(entry => ({
337
- entry,
352
+ const screen = screenDimensions(index.records, input.requirements);
353
+ const rankedIndex = screen.survivors.map(({ record: entry, check }) => ({
354
+ entry, check,
338
355
  ranking: scoreIndexEntry(entry, semanticQuery, input.policy?.prefer_system_candidates !== false),
339
356
  })).sort((a, b) => b.ranking.score - a.ranking.score || String(a.entry.model).localeCompare(String(b.entry.model)));
340
- const shortlist = rankedIndex.slice(0, topK);
357
+ const shortlist = shortlistByDimensionTier(rankedIndex, topK, maxFetch, item => item.check.status);
341
358
  const loaded = await mapConcurrent(shortlist, concurrency, async item => ({
342
359
  document: await wikiLoader(item.entry), indexRanking: item.ranking,
343
360
  }));
344
- const candidates = loaded.map(({ document, indexRanking }) => {
361
+ const candidates = orderByDimensionTier(loaded.map(({ document, indexRanking }) => {
345
362
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
346
- return candidateFromDocument(document, config, ranking, includeContent, indexRanking.score);
347
- }).sort((a, b) => b.semantic_score - a.semantic_score || b.index_score - a.index_score || String(a.model).localeCompare(String(b.model)));
363
+ const candidate = candidateFromDocument(document, config, ranking, includeContent, indexRanking.score);
364
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
365
+ }).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);
348
366
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
349
367
  results.push({
350
368
  slot_id: slot.slot_id,
351
369
  category_path: slot.category_path,
352
370
  retrieval_mode: "category_index",
371
+ dimension_screen: screen.summary,
353
372
  available_documents: index.records.length,
354
373
  indexed_candidates: index.records.length,
355
374
  fetched_documents: shortlist.length,
@@ -361,19 +380,19 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
361
380
  results,
362
381
  unmatchedSlots,
363
382
  unknowns: [],
364
- sourceSnapshot: `tos:${config.bucket}:${config.prefix}:catalog:${catalogObject.etag}:indexes:${shardsDigest}`,
383
+ sourceSnapshot: `tos:${config.bucket}:${config.prefix}:catalog:${catalogObject.etag}:indexes:${shardsDigest}:content:${catalogObject.raw_sha256}`,
365
384
  retrievalMode: "category_index",
366
385
  };
367
386
  }
368
387
 
369
- async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, includeContent }) {
388
+ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, maxFetch, includeContent }) {
370
389
  const manifestKey = `${config.prefix}_manifest.json`;
371
390
  const manifestObject = await getObject(config, manifestKey, fetchImpl);
372
391
  const manifest = parseJsonObject(manifestObject, "WIKI_MANIFEST_INVALID", "TOS hardware Wiki manifest is not valid JSON", manifestKey);
373
392
  if (!Array.isArray(manifest)) throw error("WIKI_MANIFEST_INVALID", "TOS hardware Wiki manifest must be an array", { key: manifestKey });
374
393
  const scanLimit = Number.isInteger(input.policy?.scan_limit_per_slot) && input.policy.scan_limit_per_slot > 0
375
394
  ? input.policy.scan_limit_per_slot : null;
376
- const wikiLoader = createWikiLoader(config, fetchImpl);
395
+ const wikiLoader = createWikiLoader(config, fetchImpl, { legacy: true });
377
396
  const results = [];
378
397
  const unmatchedSlots = [];
379
398
  const unknowns = [{ code: "SEARCH_INDEX_FALLBACK", message: "category search catalog missing; legacy Manifest full scan was explicitly enabled" }];
@@ -385,10 +404,11 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, i
385
404
  }
386
405
  const loaded = await mapConcurrent(entries, envPositiveInteger(env, "TOS_SEARCH_CONCURRENCY", 8), wikiLoader);
387
406
  const semanticQuery = semanticQueryFor(input, slot);
388
- const candidates = loaded.map(document => {
407
+ const candidates = shortlistByDimensionTier(loaded.map(document => {
389
408
  const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
390
- return candidateFromDocument(document, config, ranking, includeContent, ranking.score);
391
- }).sort((a, b) => b.semantic_score - a.semantic_score || String(a.model).localeCompare(String(b.model))).slice(0, topK);
409
+ const candidate = candidateFromDocument(document, config, ranking, includeContent, ranking.score);
410
+ return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
411
+ }).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);
392
412
  if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
393
413
  results.push({
394
414
  slot_id: slot.slot_id, category_path: slot.category_path, retrieval_mode: "legacy_manifest_full_scan",
@@ -423,9 +443,9 @@ export async function searchHardware({ input, env, fetchImpl = fetch }) {
423
443
  });
424
444
  }
425
445
  const searched = catalogObject
426
- ? await searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, includeContent })
427
- : await searchWithLegacyManifest({ input, env, config, fetchImpl, topK, includeContent });
428
- const id = `search-${sha256(JSON.stringify({ query: input.query ?? "", slots: input.slots, sourceSnapshot: searched.sourceSnapshot })).slice(0, 16)}`;
446
+ ? await searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, maxFetch: maxFullWikiFetch, includeContent })
447
+ : await searchWithLegacyManifest({ input, env, config, fetchImpl, topK, maxFetch: maxFullWikiFetch, includeContent });
448
+ const id = `search-${sha256(JSON.stringify({ query: input.query ?? "", request_digest: selectionRequestDigest(input), slots: input.slots, sourceSnapshot: searched.sourceSnapshot })).slice(0, 16)}`;
429
449
  return {
430
450
  id,
431
451
  status: "completed",
@@ -3,7 +3,12 @@ import { sha256 } from "./public-page.mjs";
3
3
  import { digest } from "./selection-request.mjs";
4
4
  import { redactHardwareResponse } from "./response-redaction.mjs";
5
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 } });
6
+ // Plans cap count at 5 (P2-05.02). Evidence saved by <=0.4.0 used the old default of 10;
7
+ // provider-chain-contract still accepts that frozen shape when validating saved receipts.
8
+ export const volcMaxCount = 5;
9
+ const volcBody = (item, count) => ({ Query: item.query, SearchType: "web", Count: count, Filter: { NeedUrl: true, NeedContent: false }, ContentFormats: "markdown", QueryControl: { QueryRewrite: false } });
10
+ export const volcQueryRequest = item => volcBody(item, item.count ?? volcMaxCount);
11
+ export const legacyVolcQueryRequest = item => volcBody(item, item.count ?? 10);
7
12
  export function validateVolcCapability(body) {
8
13
  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
14
  }
@@ -22,7 +22,10 @@ export function positiveEnv(env, name, fallback) {
22
22
  export function webConfig(env = {}) {
23
23
  const dnsTransport = env.ABELIEN_HARDWARE_WEB_DNS_TRANSPORT || "system";
24
24
  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),
25
+ // Prismer (query, URL-load and Parse) is temporarily off by default (P2-05.02).
26
+ const prismer = env.ABELIEN_HARDWARE_WEB_PRISMER || "disabled";
27
+ if (!["enabled", "disabled"].includes(prismer)) throw failure("HARDWARE_WEB_CONFIG", "Prismer switch must be enabled or disabled");
28
+ return { dnsTransport, prismer, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
26
29
  pageTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PAGE_TIMEOUT_MS", 30000),
27
30
  parseTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PARSE_TIMEOUT_MS", 120000) };
28
31
  }
@@ -76,8 +76,13 @@ export async function searchProviderChain({ input, source, existing, env, readRe
76
76
  if (attempt.kind === "parse" && text(receipt.response?.taskId)) { attempt.task_id = receipt.response.taskId; attempt.status = "pending"; }
77
77
  await save();
78
78
  };
79
+ const skipPrismer = async attempt => { Object.assign(attempt, { status: "unavailable", reason: "prismer_disabled" }); await save(); };
79
80
  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)); }
81
+ if (!entry.attempts.length) {
82
+ const attempt = makeAttempt(entry, "prismer", "query", entry.request);
83
+ if (config.prismer === "disabled") await skipPrismer(attempt);
84
+ else await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs));
85
+ }
81
86
  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
87
  const selected = expectedSelection(entry, true);
83
88
  if (!selected) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "original query effect is not known selectable");
@@ -131,7 +136,8 @@ export async function searchProviderChain({ input, source, existing, env, readRe
131
136
  const operation = sourceOperation(page.source_url);
132
137
  if (!page.attempts.length) {
133
138
  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));
139
+ if (config.prismer === "disabled") await skipPrismer(attempt);
140
+ else await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
135
141
  }
136
142
  if (permitsFallback(page.attempts[0], false) && page.attempts.length === 1) {
137
143
  const attempt = makeAttempt(page, "direct_https", "direct_page", { url: page.source_url, method: "GET" });
@@ -1,8 +1,12 @@
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 }) {
8
+ // The shape check alone is used for a plan already frozen in saved evidence.
9
+ export async function validateFrozenHardwareWebPlan({ input, readResource }) {
6
10
  onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots"], "web search input");
7
11
  const source = await boundRecall(input, readResource), recall = source.body;
8
12
  if (!Array.isArray(input.unresolved_slots)) invalid("unresolved_slots must be an array");
@@ -16,3 +20,14 @@ export async function validateHardwareWebPlan({ input, readResource }) {
16
20
  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
21
  return { source };
18
22
  }
23
+ // New plans (P2-05.02): English queries for Volc and at most 5 results per query.
24
+ export function validateNewHardwareWebPlan(input) {
25
+ for (const item of input.unresolved_slots) {
26
+ if (item.count !== undefined && item.count > volcMaxCount) invalid(`search Count must be an integer from 1 to ${volcMaxCount}`);
27
+ if (cjk.test(item.query)) invalid("search query must be written in English; keep model numbers and manufacturer names");
28
+ }
29
+ }
30
+ export async function validateHardwareWebPlan({ input, readResource }) {
31
+ const result = await validateFrozenHardwareWebPlan({ input, readResource });
32
+ validateNewHardwareWebPlan(input); return result;
33
+ }
@@ -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 } 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,15 @@ 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.
27
+ if (existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
26
28
  if (!existing || existing.body.protocol === chainProtocol) return searchProviderChain({ input, source, existing: storedExisting, env, readResource, fetchImpl, ports, checkpoint, readBlob, writeBlob });
27
29
  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
30
  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.4.0",
3
+ "version": "0.6.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 选择的型号、数量、逐约束引用与兼容评估(电源与接口可由可信电气数据确定性判定,结构/散热/软件只提示),保存选型、可选尺寸占用摘要与兼容报告;未知不通过门禁",
10
10
  "adapter": {
11
11
  "type": "module",
12
12
  "path": "scripts/select.mjs",