@sema-agent/settings-schema 11.3.0 → 12.0.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 12.0.0 — 2026-10-12 — 角色词随引擎收缩(`ModelRole` 8 → 6,一次性迁移 SS-M8)· 写失败按 errno 落点分句 · `config.d` 每份文档同一只文本判 · 扣名条目的可比记号 `identity.token`(major)
10
+
11
+ > 本节一处硬破坏(角色词表收缩,持久面走一次性迁移 SS-M8)外加三件:命令行写失败按落点分句、`config.d` 每份文档同一只文本判(`remote-exec.json` 非对象不再被 `migrate`
12
+ > 当成无事可做)、扣名条目带可比记号(新导出 `withheldNameToken`,additive)。车道表的租户依据列 `tenantSeparationBasis` 11.0.0 起已是数据,本版无改动。
13
+ > 消费方:server 与引擎 core 7.49.0 同批提货(读点见各条);web-admin 的角色编辑面读 `ModelRole.options`,随表收缩、零改动。版号日期与节标题由发版方写。
14
+
15
+ ### BREAKING:`ModelRole` 收成 6 词 —— 删 `team` / `synthesize`(随引擎 core 7.49.0)
16
+
17
+ - **删什么**:`ModelRole` = `default` / `summarize` / `subagent` / `verifier` / `classifier` / `consolidate`;`team` / `synthesize` 直接删,零别名、零双读。
18
+ `ROSTER_PRIMARY_ROLES` = `["default"]`:roster 的 `primaryModel` 标记只派生 `roles.default`(`ROSTER_CHEAP_ROLES` 不变)。`advisor` 本包从未收。
19
+ - **谁会红**:把 `"team"` / `"synthesize"` 当 `ModelRole` 写的代码 tsc 红;存着 `roles.team` / `roles.synthesize`(`models.roles` 或任一 `rosters[].roles`)的文档
20
+ 在迁移之前到达判官 —— 写侧拒;读侧丢那一条角色(models 里只剩它时整域降级到缺省;rosters 是闸域,整个 scope 读不出)—— 拒句点名一次性迁移 SS-M8。
21
+ - **怎么办**:起服 / 提货前跑 SS-M8(函数 `migrateStoredDocuments`,或 `npx @sema-agent/settings-schema migrate <config.d directory>`),把读 `team` / `synthesize`
22
+ 角色的地方改成读 `default` 或删掉。
23
+
24
+ | 迁移 | 改写什么 | 收据 | 删码之后谁接住 |
25
+ |---|---|---|---|
26
+ | SS-M8 | `models.roles` 与每个 `rosters[].roles` 上的 `team` / `synthesize` 键删掉(值不论);别的角色与别的键原样 | 逐坐标一张 `dropped-inert`(`roles.team` / `rosters.<i>.roles.synthesize` …);再跑零收据 | 退役角色词墓碑(永久):残留的键读写两面拒 |
27
+
28
+ 窗与删法照例只登记在 `ONE_TIME_MIGRATIONS`。收据的迁移 id 闭集多一个 `SS-M8`(`ONE_TIME_MIGRATION_IDS`)。
29
+ - **谁要改**:server 按 `ROSTER_PRIMARY_ROLES` 给每个词写角色的那一处随表只剩 `default`;读 `team` / `synthesize` 角色的消费点(团队 / 合议 / 协作类能力)改读 `default`
30
+ 或随引擎退役;起服对本地 `config.d` 的迁移与提货前对存量文档跑一次 SS-M8。
31
+
32
+ ### 行为修复:命令行写失败按 errno 落点分句
33
+
34
+ `migrate` 写回阶段写盘失败以前一句话包打天下:「cannot write <文件>: <Node 的错误句> … fix the cause and re-run」—— 目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
35
+ 而可写目录里的只读文件照常迁;「fix the cause」也没说是哪个。现在原子写器按失败的那一步出句子,句尾是对应出路:
36
+ - 临时文件建不出(目录不可写:`EACCES` / `EPERM` / `EROFS` / `ENOENT`)⇒ `cannot write into directory <dir> (<errno>): make the directory writable`;
37
+ - `rename` 被拒(`EACCES` / `EPERM`:不可变标志、粘滞目录里别人的文件)⇒ `cannot replace <file> (<errno>): make the file replaceable — …`;
38
+ - 别的 errno(磁盘满、I/O 错 …)⇒ 同两种开头,句尾是 Node 自己的句子。
39
+ 命令行 stderr 一行 = `settings-schema: <那一句> — files written before it keep their migrated form; re-run once that is fixed`,rc 2 不变。
40
+ 库写口(`writeDomainFile` / `writeRemoteExec` / 调度店写口)抛的是同一句的错误:`code` 照抄底层 errno、原错误在 `cause`(按 `code` 分支的调用方不受影响)。
41
+
42
+ ### 行为修复:`config.d` 每份文档同一只文本判 —— `remote-exec.json` 非对象不再被 `migrate` 当成「无事可做」
43
+
44
+ 同一条 `migrate`,`models.json` 写成 `[]` ⇒ rc 2,`remote-exec.json` 写成 `[]` / `null` / `5` / `"x"` / `true` ⇒ noop rc 0、文件不动(它走的是另一只只拒空与坏 JSON 的文本判)。
45
+ 现在 `config.d` 里每份文档(域文档、退役域文档、`remote-exec.json`)过同一只文本判,三种坏形每种一句、点名 `config.d/<名>.json` 与形,句尾出路按文档类:
46
+ - 空 / 纯空白 ⇒ `config.d/<名>.json is empty/whitespace — <出路>`;坏 JSON ⇒ `invalid JSON in config.d/<名>.json — <出路>`;不是对象 ⇒ `config.d/<名>.json must be a JSON object (got <形>) — <出路>`;
47
+ - 出路:域 ⇒ `delete it for the domain default, or write a valid object`;`remote-exec` ⇒ `delete it for the host default, or write a valid remoteExec spec`。
48
+ `FileConfigStore` / `readBundle` / `loadRemoteExec` / 命令行读同一只。措辞随之归一的两处:域文档的坏 JSON 句补上出路;非对象句「delete the file」改成「delete it」(与空文件句同)。
49
+ `loadRemoteExec` 的三句改点名 `config.d/remote-exec.json`(不再是绝对路径),非对象改为上面那句(以前是联合判的 zod 句)—— 抛不抛都不变。
50
+ - **订正 11.3.0 节**:那一节「行为变(命令行)」第二句说「`remote-exec.json` 是空 / 坏 JSON 时 … ⇒ 读不进 rc 2」—— 在 11.3.0 上只对空与坏 JSON 成立,
51
+ 非对象(数组 / `null` / 数 / 串 / 布尔)仍是 noop rc 0。从本版起五种形一律 rc 2,那句话才完整为真。
52
+
53
+ ### 新导出:`withheldNameToken` —— 扣名条目带可比记号 `identity.token`(additive)
54
+
55
+ 被丢条目的名不能原样出门(秘密形 / 超长 / 字符集外)时,身份以前只带 `nameWithheld: true`:两条扣名行在消费方眼里一模一样,「请求点名的那个模型是不是这条坏行」也无从比对。
56
+ 现在扣名的同时带 `token` = `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形 —— 逐个 UTF-16 码元保留,孤立代理项不会与 U+FFFD 撞成同一输入)的前 12 个十六进制位 ——
57
+ 稳定(同名恒同记号)、不可逆、原名一个字节都不出门;
58
+ `DroppedEntryIdentity` 加可选键 `token`(与 `nameWithheld` 同在同缺)。唯一铸法 `withheldNameToken(name)`(主桶与 `./config-fns` 各 +1)。
59
+ 身份只回答「丢的是谁」:在场与否是消费方按原文判的事(注释里「没有名的行不是在场条目」一句删去,与消费方更严的读法一致)。
60
+ 一致性语料里带扣名身份的 6 条 case,金样随之多 `token` 一键。
61
+ - **谁要改**:server 读被丢条目身份的那一处,对请求里显式点名的模型调 `withheldNameToken` 与扣名行的 `token` 比对 —— 命中即「点名的是一条坏行」(而不是「未知模型」);不另写摘要。
62
+
9
63
  ## 11.3.0 — 2026-10-11 — 六件 additive:运行期成员表三张(AUTONOMY_MODES / COMMAND_DECISIONS / PROMPT_SLOTS)· 项目登记扩形(goal / contextItems / archivedAt / icon / color,displayName 上界 200)+ PATCH 形 · 命令行环境错一臂(I/O 错 rc 2 一句点名路径)· 一次性迁移 SS-M7 走到 remote-exec.json · 每条自铸 custom issue 带结构化句子(QuotedIssueParams)· hookMatcherForm(minor:新导出主桶 19 / hooks 2 / remote-exec 1 / node 1 型,零删零改名)
10
64
 
11
65
  > 本节六件,全部 additive(零删导出、零改名;minor):运行期成员表三张 · 项目登记扩形 + PATCH 形 · 命令行环境错一臂 · 一次性迁移 SS-M7 走到 `remote-exec.json` ·
@@ -1 +1 @@
1
- 11.3.0
1
+ 12.0.0
@@ -80,7 +80,8 @@
80
80
  "domain": "models",
81
81
  "identity": {
82
82
  "id": "withheld-id",
83
- "nameWithheld": true
83
+ "nameWithheld": true,
84
+ "token": "withheld:5c1dac79e895"
84
85
  },
85
86
  "kind": "entry-dropped",
86
87
  "path": [
@@ -107,7 +108,8 @@
107
108
  "domain": "models",
108
109
  "identity": {
109
110
  "id": "short-bearer-id",
110
- "nameWithheld": true
111
+ "nameWithheld": true,
112
+ "token": "withheld:8d196a0cf81a"
111
113
  },
112
114
  "kind": "entry-dropped",
113
115
  "path": [
@@ -120,7 +122,8 @@
120
122
  "domain": "models",
121
123
  "identity": {
122
124
  "id": "pem-id",
123
- "nameWithheld": true
125
+ "nameWithheld": true,
126
+ "token": "withheld:ffe22d022741"
124
127
  },
125
128
  "kind": "entry-dropped",
126
129
  "path": [
@@ -93,7 +93,8 @@
93
93
  "domain": "models",
94
94
  "identity": {
95
95
  "id": "in-charset-secret-name-id",
96
- "nameWithheld": true
96
+ "nameWithheld": true,
97
+ "token": "withheld:e4c2e8ebcfc8"
97
98
  },
98
99
  "kind": "entry-dropped",
99
100
  "path": [
@@ -106,7 +107,8 @@
106
107
  "domain": "models",
107
108
  "identity": {
108
109
  "id": "one-dot-jwt-name-id",
109
- "nameWithheld": true
110
+ "nameWithheld": true,
111
+ "token": "withheld:c30e4531b782"
110
112
  },
111
113
  "kind": "entry-dropped",
112
114
  "path": [
@@ -112,7 +112,8 @@
112
112
  "domain": "models",
113
113
  "identity": {
114
114
  "id": "cap-257-id",
115
- "nameWithheld": true
115
+ "nameWithheld": true,
116
+ "token": "withheld:389e8c0da200"
116
117
  },
117
118
  "kind": "entry-dropped",
118
119
  "path": [
@@ -174,7 +175,8 @@
174
175
  {
175
176
  "domain": "hosts",
176
177
  "identity": {
177
- "nameWithheld": true
178
+ "nameWithheld": true,
179
+ "token": "withheld:56d720b6a52f"
178
180
  },
179
181
  "kind": "entry-dropped",
180
182
  "path": [
@@ -70,7 +70,8 @@
70
70
  {
71
71
  "domain": "hosts",
72
72
  "identity": {
73
- "nameWithheld": true
73
+ "nameWithheld": true,
74
+ "token": "withheld:e2bdf1a6eb87"
74
75
  },
75
76
  "kind": "entry-dropped",
76
77
  "path": [
@@ -76,7 +76,8 @@
76
76
  "domain": "models",
77
77
  "identity": {
78
78
  "idWithheld": true,
79
- "nameWithheld": true
79
+ "nameWithheld": true,
80
+ "token": "withheld:b2fca8da7930"
80
81
  },
81
82
  "kind": "entry-dropped",
82
83
  "path": [
@@ -89,7 +90,8 @@
89
90
  "domain": "models",
90
91
  "identity": {
91
92
  "idWithheld": true,
92
- "nameWithheld": true
93
+ "nameWithheld": true,
94
+ "token": "withheld:946256d8ea6b"
93
95
  },
94
96
  "kind": "entry-dropped",
95
97
  "path": [
@@ -89,7 +89,8 @@
89
89
  "domain": "models",
90
90
  "identity": {
91
91
  "idWithheld": true,
92
- "nameWithheld": true
92
+ "nameWithheld": true,
93
+ "token": "withheld:f3b4942da5cd"
93
94
  },
94
95
  "kind": "entry-dropped",
95
96
  "path": [
@@ -102,7 +103,8 @@
102
103
  "domain": "models",
103
104
  "identity": {
104
105
  "idWithheld": true,
105
- "nameWithheld": true
106
+ "nameWithheld": true,
107
+ "token": "withheld:2469fd783cfd"
106
108
  },
107
109
  "kind": "entry-dropped",
108
110
  "path": [
@@ -19,10 +19,16 @@
19
19
  * (不在链接位置建普通文件,也不替链接建它指向的文件);成环的链接由 `realpath` 抛 `ELOOP`。目标不存在 ⇒ 新建(没有链接可言;经符号链接的目录写新文件时,文件落在真目录里)。
20
20
  * 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
21
21
  * 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
22
+ *
23
+ * ## 写失败(12.0.0)
24
+ * 两步各一句({@link AtomicWriteFailure},句子在 `./write-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
25
+ * 文件本身没错);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。认得的 errno 给对应出路,别的给 Node 自己的句子;`code` / `cause` 留着。
26
+ * 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。
22
27
  */
23
28
  import { randomBytes } from "node:crypto";
24
29
  import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
25
30
  import path from "node:path";
31
+ import { AtomicWriteFailure } from "./write-failure.js";
26
32
  /** 按文档类的模式表(闭集)。 */
27
33
  export const ATOMIC_FILE_CLASSES = {
28
34
  "config-document": { newMode: 0o644, existing: "keep" },
@@ -79,11 +85,14 @@ export function writeFileAtomic(target, text, kind) {
79
85
  throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
80
86
  }
81
87
  }
82
- const tmp = path.join(path.dirname(real), `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
88
+ const dir = path.dirname(real);
89
+ const tmp = path.join(dir, `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
90
+ let step = "create";
83
91
  try {
84
92
  writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
85
93
  if (kept !== undefined)
86
94
  chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
95
+ step = "replace";
87
96
  renameSync(tmp, real);
88
97
  }
89
98
  catch (e) {
@@ -93,6 +102,6 @@ export function writeFileAtomic(target, text, kind) {
93
102
  catch {
94
103
  /* 临时文件没建成:没什么可清 */
95
104
  }
96
- throw e;
105
+ throw new AtomicWriteFailure(step, dir, real, e); // 12.0.0: the sentence names the step that failed and its way out (`./write-failure`)
97
106
  }
98
107
  }
package/dist/cli.d.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
- * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
19
+ * 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
20
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
20
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
21
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
22
23
  *
package/dist/cli.js CHANGED
@@ -15,8 +15,9 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
- * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
19
+ * 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
20
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
20
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
21
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
22
23
  *
@@ -30,6 +31,7 @@
30
31
  import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
31
32
  import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
32
33
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
34
+ import { AtomicWriteFailure } from "./write-failure.js";
33
35
  import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
34
36
  /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
35
37
  export const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS), REMOTE_EXEC_DOCUMENT];
@@ -77,7 +79,8 @@ class EnvironmentFailure extends Error {
77
79
  }
78
80
  const oneLine = (text) => text.replace(/\s*\n\s*/g, " ");
79
81
  const messageOf = (err) => oneLine(err instanceof Error ? err.message : String(err));
80
- /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure},句子点名**要写的那份文件**(原子写器的错误句里是临时文件的路径)与原错误句。
82
+ /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure}。本包的原子写器抛的是按失败那一步分好的句子({@link AtomicWriteFailure}:目录建不出文件 ⇒
83
+ * 点名目录,目标换不掉 ⇒ 点名文件,句尾是对应出路)—— 原样用;别的写口(注入的替身)抛的错 ⇒ 点名**要写的那份文件**与原错误句。
81
84
  * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2)。 */
82
85
  function withEnvironmentArm(io) {
83
86
  return {
@@ -88,7 +91,7 @@ function withEnvironmentArm(io) {
88
91
  await io.writeFileAtomic(path, text);
89
92
  }
90
93
  catch (err) {
91
- throw new EnvironmentFailure(`cannot write ${path}: ${messageOf(err)}`, { cause: err });
94
+ throw new EnvironmentFailure(err instanceof AtomicWriteFailure ? err.message : `cannot write ${path}: ${messageOf(err)}`, { cause: err });
92
95
  }
93
96
  },
94
97
  removeFile: async (path) => {
@@ -125,7 +128,7 @@ export async function runSettingsSchemaCli(argv, io) {
125
128
  if (!(err instanceof EnvironmentFailure))
126
129
  throw err;
127
130
  // 写回阶段断在这里:之前写完的文档已在新形上,再跑一次幂等收尾(迁移只认形)。stdout 不出结论行 —— 没有 `outcome=applied`。
128
- io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; fix the cause and re-run\n`);
131
+ io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; re-run once that is fixed\n`);
129
132
  return 2;
130
133
  }
131
134
  switch (m.kind) {
@@ -1,20 +1,19 @@
1
- import { RemoteExecSpec } from "./remote-exec.js";
1
+ import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
2
2
  import { type DomainName, type RetiredDomainName } from "./types.js";
3
3
  /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
4
4
  * 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
5
5
  export declare function configDDocumentText(value: unknown): string;
6
- /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
7
- * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
8
- * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
9
- export declare function judgeRemoteExecText(filePath: string, text: string): unknown;
10
6
  /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
11
7
  * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
12
8
  export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
13
9
  /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
14
10
  export declare function configDPath(root: string, name: string): string;
11
+ /** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
12
+ export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
15
13
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
16
- * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
17
- export declare function judgeConfigDText(name: DomainName | RetiredDomainName, text: string): Record<string, unknown>;
14
+ * 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
15
+ * 都过它 —— 一种坏形一句话,不论是哪份文档。 */
16
+ export declare function judgeConfigDText(name: ConfigDDocumentName, text: string): Record<string, unknown>;
18
17
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
19
18
  export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
20
19
  /**
@@ -1,36 +1,23 @@
1
1
  /**
2
- * 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle` 与命令行 `migrate` 读同一个目录,
3
- * 同一种文件形同一个答案、同一句话。分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行自己读文本,只调它)与读盘的
4
- * `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
2
+ * 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle`、`loadRemoteExec` 与命令行 `migrate` 读同一个目录,
3
+ * 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON,非对象在命令行上被当成「无事可做」)。
4
+ * 分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行与 `loadRemoteExec` 自己读文本,只调它)与读盘的 `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
5
5
  *
6
6
  * 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
7
7
  * (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
8
- * governance.json 丢掉它的护栏)。拒句只点名文件与形,从不回显文件原文。
8
+ * governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名文件与形、句尾出路按文档类
9
+ * ({@link documentWayOut}),从不回显文件原文。
9
10
  */
10
11
  import { readFile } from "node:fs/promises";
11
12
  import path from "node:path";
12
13
  import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
13
- import { RemoteExecSpec } from "./remote-exec.js";
14
+ import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
14
15
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
15
16
  /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
16
17
  * 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
17
18
  export function configDDocumentText(value) {
18
19
  return `${JSON.stringify(value, null, 2)}\n`;
19
20
  }
20
- /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
21
- * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
22
- * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
23
- export function judgeRemoteExecText(filePath, text) {
24
- const trimmed = text.trim();
25
- if (!trimmed)
26
- throw new Error(`${filePath} is empty/corrupt — delete it for the host default, or write a valid remoteExec spec`);
27
- try {
28
- return JSON.parse(trimmed); // sanitized: don't let a SyntaxError echo the raw (possibly secret-bearing) file text
29
- }
30
- catch {
31
- throw new Error(`${filePath} — invalid JSON (delete it for the host default, or write a valid remoteExec spec)`);
32
- }
33
- }
34
21
  /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
35
22
  * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
36
23
  export function parseRemoteExecDocument(raw) {
@@ -51,22 +38,28 @@ export function configDPath(root, name) {
51
38
  throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
52
39
  return path.join(root, "config.d", `${name}.json`);
53
40
  }
41
+ /** 坏文件句的出路(按文档类):删掉它 = 回到缺席时的缺省(域 ⇒ 域缺省;`remote-exec` ⇒ 宿主车道缺省),或写成那一类的合法文档。 */
42
+ function documentWayOut(name) {
43
+ return name === REMOTE_EXEC_DOCUMENT ? "delete it for the host default, or write a valid remoteExec spec" : "delete it for the domain default, or write a valid object";
44
+ }
54
45
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
55
- * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
46
+ * 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
47
+ * 都过它 —— 一种坏形一句话,不论是哪份文档。 */
56
48
  export function judgeConfigDText(name, text) {
49
+ const file = `config.d/${name}.json`;
57
50
  const trimmed = text.trim();
58
51
  if (!trimmed)
59
- throw new Error(`config.d/${name}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
52
+ throw new Error(`${file} is empty/whitespace — ${documentWayOut(name)}`);
60
53
  let parsed;
61
54
  try {
62
55
  parsed = JSON.parse(trimmed);
63
56
  }
64
57
  catch {
65
- throw new Error(`invalid JSON in config.d/${name}.json`); // sanitized: never echo the raw file text into the error
58
+ throw new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`); // sanitized: never echo the raw file text into the error
66
59
  }
67
60
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
68
61
  const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
69
- throw new Error(`config.d/${name}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
62
+ throw new Error(`${file} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
70
63
  }
71
64
  return parsed;
72
65
  }
@@ -1,6 +1,5 @@
1
+ import { type ConfigDDocumentName } from "./config-d-file.js";
1
2
  import { type MigrationReceipt } from "./one-time-migrations.js";
2
- import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
3
- import { type DomainName, type RetiredDomainName } from "./types.js";
4
3
  /** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
5
4
  export interface ConfigDirectoryIo {
6
5
  readDir(path: string): Promise<string[]>;
@@ -50,8 +49,8 @@ export interface MigrateConfigDirectoryOptions {
50
49
  /** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
51
50
  readonly check?: boolean;
52
51
  }
53
- /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)。 */
54
- export type ConfigDirectoryDocument = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
52
+ /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)—— 就是 `config.d` 里能放的文档名。 */
53
+ export type ConfigDirectoryDocument = ConfigDDocumentName;
55
54
  /** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
56
55
  export declare const joinDirPath: (dir: string, name: string) => string;
57
56
  type RefusedReceipt = Extract<MigrationReceipt, {
@@ -23,16 +23,12 @@
23
23
  */
24
24
  import { z } from "zod";
25
25
  import { formatConfigPath, parseDomain } from "./config-fns.js";
26
- import { configDDocumentText, judgeConfigDText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
26
+ import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
27
27
  import { migrateStoredDocuments } from "./one-time-migrations.js";
28
28
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
29
29
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
30
30
  import { isDomainName, isRetiredDomainName } from "./types.js";
31
31
  const isConfigDirectoryDocument = (name) => isDomainName(name) || isRetiredDomainName(name) || name === REMOTE_EXEC_DOCUMENT;
32
- /** 一份文档在场时的文本 → 值:域 / retired 域过 `judgeConfigDText`(与 `FileConfigStore` 同一只),`remote-exec` 过 `judgeRemoteExecText`(与 `loadRemoteExec` 同一只)。 */
33
- function judgeDocumentText(dir, name, text) {
34
- return name === REMOTE_EXEC_DOCUMENT ? judgeRemoteExecText(joinDirPath(dir, `${name}.json`), text) : judgeConfigDText(name, text);
35
- }
36
32
  /** 改过的文档的整文档终判:域过读侧那一只 `parseDomain`,`remote-exec` 过 `loadRemoteExec` 那一只 `parseRemoteExecDocument`;retired 域不终判(它只会被删)。 */
37
33
  function judgeWholeDocument(name, doc) {
38
34
  if (isDomainName(name))
@@ -130,7 +126,7 @@ export async function migrateConfigDirectory(dir, opts) {
130
126
  return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
131
127
  }
132
128
  try {
133
- docs[base] = judgeDocumentText(dir, base, text); // the one judge per document kind: a domain file's is FileConfigStore's, remote-exec.json's is loadRemoteExec's
129
+ docs[base] = judgeConfigDText(base, text); // the one config.d text judge (FileConfigStore / loadRemoteExec read with it): empty / bad JSON / not an object ⇒ unreadable
134
130
  }
135
131
  catch (err) {
136
132
  return { kind: "unreadable", reason: messageOf(err) };
@@ -182,8 +182,10 @@ export declare function outgoingPath(p: ConfigPath): ConfigPath;
182
182
  * 所以是**不带**而不是打码)。三键都读不出 ⇒ 整个身份缺席(不铸空对象)。
183
183
  *
184
184
  * **扣名 ≠ 读不出**(11.0.0,N-4 / S-759):`name` / `id` 是字符串但不能原样出门 ⇒ 身份带 `nameWithheld: true` / `idWithheld: true`;
185
- * 不是字符串 ⇒ 什么都不带。两种处置对消费方不同:「这一行在场、有名、名被扣下」的启用行仍是一条在场的条目(server 据此铸无名占位,
186
- * 不走「零启用」捷径),「没有名」的行不是。标记只说「有一个名、没给出」,不说为什么(秘密形 / 超长 / 字符集外是同一个记号)。
185
+ * 不是字符串 ⇒ 什么都不带。标记只说「有一个名、没给出」,不说为什么(秘密形 / 超长 / 字符集外是同一个记号)。扣下的**名**另带一个可比记号
186
+ * `token`(12.0.0,{@link withheldNameToken}):两条扣名行据此可分、消费方拿请求里的名字调同一只就能比对,原名一个字节都不出门。
187
+ * 身份只回答「丢的是谁」,不回答「这条坏行算不算在场」:一条读不出名字的被丢行照样是文档里的一行,在场与否是消费方按原文判的事
188
+ * (例:server 把没写 `enabled: false` 的被丢行一律算在场)。
187
189
  *
188
190
  * 为什么要带:条目被丢之后它的名字从目录里消失,只给坐标的话,消费方要知道「丢的是谁」只能拿坐标回读
189
191
  * 原文档 —— 而它手里那份原文档未必是这一次读到的那份。铸告警的这一层判的就是那条原文,在这里取一次、
@@ -194,6 +196,9 @@ export interface DroppedEntryIdentity {
194
196
  enabled?: boolean;
195
197
  /** `name` was a string that {@link isDisclosableIdentity} would not let out (11.0.0). Absent with a `name` present or with no string name. */
196
198
  nameWithheld?: true;
199
+ /** The withheld name's comparable token ({@link withheldNameToken}; 12.0.0) — present exactly when `nameWithheld` is. Stable (the same name
200
+ * always gives the same token), irreversible, never the name or any part of it. */
201
+ token?: string;
197
202
  /** `id` was a string that {@link isDisclosableIdentity} would not let out (11.0.0). */
198
203
  idWithheld?: true;
199
204
  }
@@ -251,6 +256,13 @@ export declare function isDisclosableIdentity(v: string): boolean;
251
256
  * ② {@link isDisclosableIdentity}(不是白名单闭形里的标识符,或是秘密形 ⇒ 不出门)。
252
257
  * 非字符串 ⇒ false。住在本文件而不是 `./fleet`:`fleet.ts` 被 `types.ts` 引,它再引本文件会成环。 */
253
258
  export declare function isReportableRefusalName(name: string): boolean;
259
+ /** 扣下的名的**可比记号**(12.0.0)—— `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形)的前 12 个十六进制位。**唯一**铸法:被丢条目的身份
260
+ * ({@link DroppedEntryIdentity}`.token`)由它铸,消费方要问「请求点名的这个名是不是那条扣名行」就对请求里的名调它、比 token —— 不另写一份摘要。
261
+ * 稳定(同名恒同记号)、不可逆(48 位摘要,秘密形名字本身是高熵串);域分隔前缀让它与别处对同一串算的 SHA-256 指纹对不上。它只区分名字,
262
+ * 不是 MAC、不防字典猜测:低熵的名(`Basic Chat`)能被猜回来 —— 那类名被扣下是因为字符集外,本就不是秘密。
263
+ * 摘要输入取 `JSON.stringify(name)`:UTF-8 编码会把孤立代理项换成 U+FFFD(`"\ud800"` / `"\ud801"` / `"\ufffd"` 三个不同的名同一串字节),
264
+ * JSON 串形逐个 UTF-16 码元保留(孤立代理项写成转义)—— 不同的名恒是不同的输入。 */
265
+ export declare function withheldNameToken(name: string): string;
254
266
  /** 抢救造成的**下标位移表**:数组字段名 → 该字段压缩后每个位置的**原**下标(`kept[j]` = 压缩后第 j 条
255
267
  * 在原文档里的下标)。纯数据,无行为。
256
268
  *
@@ -18,6 +18,7 @@
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
20
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
21
+ import { sha256Hex } from "./sha256.js";
21
22
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
22
23
  import { DOMAIN_SCHEMAS, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, RETIRED_DOMAINS, SkillsManifestSchema, containsSecretToken, isDomainName, isRetiredDomainName, renderQuotedSentence, retiredDomainSentence } from "./types.js";
23
24
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
@@ -327,6 +328,17 @@ export function isReportableRefusalName(name) {
327
328
  const probe = ModelRefusalsWire.safeParse({ entries: { [name]: MODEL_ENTRY_REFUSAL_CODES[0] } });
328
329
  return probe.success && Object.hasOwn(probe.data.entries, name);
329
330
  }
331
+ /** {@link withheldNameToken} 的域分隔前缀(摘要输入 = 前缀 + 原名的 JSON 串形)。 */
332
+ const WITHHELD_NAME_TOKEN_DOMAIN = "sema-settings-schema/withheld-name\u0000";
333
+ /** 扣下的名的**可比记号**(12.0.0)—— `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形)的前 12 个十六进制位。**唯一**铸法:被丢条目的身份
334
+ * ({@link DroppedEntryIdentity}`.token`)由它铸,消费方要问「请求点名的这个名是不是那条扣名行」就对请求里的名调它、比 token —— 不另写一份摘要。
335
+ * 稳定(同名恒同记号)、不可逆(48 位摘要,秘密形名字本身是高熵串);域分隔前缀让它与别处对同一串算的 SHA-256 指纹对不上。它只区分名字,
336
+ * 不是 MAC、不防字典猜测:低熵的名(`Basic Chat`)能被猜回来 —— 那类名被扣下是因为字符集外,本就不是秘密。
337
+ * 摘要输入取 `JSON.stringify(name)`:UTF-8 编码会把孤立代理项换成 U+FFFD(`"\ud800"` / `"\ud801"` / `"\ufffd"` 三个不同的名同一串字节),
338
+ * JSON 串形逐个 UTF-16 码元保留(孤立代理项写成转义)—— 不同的名恒是不同的输入。 */
339
+ export function withheldNameToken(name) {
340
+ return `withheld:${sha256Hex(`${WITHHELD_NAME_TOKEN_DOMAIN}${JSON.stringify(name)}`).slice(0, 12)}`;
341
+ }
330
342
  /** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
331
343
  * 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。 */
332
344
  function readEntryIdentity(entry) {
@@ -341,7 +353,7 @@ function readEntryIdentity(entry) {
341
353
  const id = own("id");
342
354
  const enabled = own("enabled");
343
355
  const identity = {
344
- ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true }) : {}),
356
+ ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true, token: withheldNameToken(name) }) : {}),
345
357
  ...(typeof id === "string" ? (isDisclosableIdentity(id) ? { id } : { idWithheld: true }) : {}),
346
358
  ...(typeof enabled === "boolean" ? { enabled } : {}),
347
359
  };
@@ -14,7 +14,7 @@ import { writeFileAtomic } from "./atomic-write.js";
14
14
  import { FileConfigStore } from "./file-store.js";
15
15
  import { listRequiredEnvNames } from "./file-edit.js";
16
16
  import {} from "./config-fns.js";
17
- import { configDDocumentText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
17
+ import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
18
18
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
19
19
  /** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
20
20
  export function remoteExecPath(root) {
@@ -34,9 +34,9 @@ export async function loadRemoteExec(root) {
34
34
  }
35
35
  // A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
36
36
  // silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
37
- // To get the host default, the file must be ABSENT, not empty. The text judge and the sanitized value judge are the ones the
38
- // directory migration reads this file with (`./config-d-file`).
39
- return parseRemoteExecDocument(judgeRemoteExecText(remoteExecPath(root), text));
37
+ // To get the host default, the file must be ABSENT, not empty. The text judge is THE config.d one (empty / bad JSON / not an object, one sentence
38
+ // each — the same judge FileConfigStore and the directory migration read with); the sanitized value judge is the migration's too (`./config-d-file`).
39
+ return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text));
40
40
  }
41
41
  /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
42
42
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
@@ -6,7 +6,7 @@ import type { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
6
6
  import type { DomainName, RetiredDomainName } from "./types.js";
7
7
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
8
8
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
9
- export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M9"];
9
+ export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9"];
10
10
  export type OneTimeMigrationId = (typeof ONE_TIME_MIGRATION_IDS)[number];
11
11
  /**
12
12
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
@@ -1,6 +1,6 @@
1
1
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
2
2
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
3
- export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M9"];
3
+ export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9"];
4
4
  /**
5
5
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
6
6
  * - `moved` — 值搬到新位(`to`),义不变;
@@ -39,7 +39,7 @@ export type StoredDocuments = Readonly<Partial<Record<DomainName | RetiredDomain
39
39
  export interface StoredDocumentsMigration {
40
40
  /** 迁移后的文档集:没改的文档是**同一引用**;被移除的(retired 域)不在;新建的(例:迁出的 `governance` / `limits`)在。 */
41
41
  readonly docs: Readonly<Record<string, unknown>>;
42
- /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M9 → SS-M7 → SS-M4:纯改写步在前,用新 schema 终判的 SS-M4 最后;步内按坐标)。
42
+ /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M8 → SS-M9 → SS-M7 → SS-M4:纯改写步在前,用新 schema 终判的 SS-M4 最后;步内按坐标)。
43
43
  * 只有 `refused` 的收据 = 什么都没改。 */
44
44
  readonly receipts: readonly MigrationReceipt[];
45
45
  }
@@ -34,6 +34,7 @@ import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-i
34
34
  import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
35
35
  import { INTEGER } from "./integer.js";
36
36
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
37
+ import { RETIRED_ROLE_WORDS } from "./retired-role-words.js";
37
38
  import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
38
39
  import { DOMAIN_SCHEMAS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, NON_SECRET_URL, isDomainName, isRetiredDomainName } from "./types.js";
39
40
  import { workerRowVerdict } from "./worker-row-verdict.js";
@@ -134,6 +135,19 @@ export const ONE_TIME_MIGRATIONS = [
134
135
  removal: "the next major after the window: delete the SS-M7 step and its schema walk; the NON_SECRET_URL fragment rule loses its SS-M7 clause",
135
136
  afterRemoval: "the NON_SECRET_URL shape judge, which refuses a fragment (`#` included) on read and write",
136
137
  },
138
+ {
139
+ id: "SS-M8",
140
+ oldForm: "refused",
141
+ introducedIn: "12.0.0",
142
+ releasedOn: "2026-10-12",
143
+ removableAfter: "2026-11-11",
144
+ rewrites: "models / rosters: role words removed by core 7.49.0: team, synthesize — the keys roles.team / roles.synthesize (models.roles and every " +
145
+ "rosters[].roles) are removed whatever their value (12.0.0 drops both words from ModelRole: no role reads them, and the engine refuses " +
146
+ "them); every other role and key stays as stored",
147
+ removal: "the next major after the window: delete the SS-M8 step; the retired role-word tombstone stays — its SS-M8 clause goes",
148
+ afterRemoval: "the retired role-word tombstone (permanent): a residual roles.team / roles.synthesize is refused on read and write (read: the entry is dropped " +
149
+ "when other roles survive, else models degrades to its default; rosters: the scope fails to read)",
150
+ },
137
151
  {
138
152
  id: "SS-M9",
139
153
  oldForm: "refused",
@@ -189,6 +203,7 @@ export function migrateStoredDocuments(docs, opts) {
189
203
  stepHostsBounds(out, receipts);
190
204
  stepModelKeyOneOf(out, receipts);
191
205
  stepAtMention(out, receipts);
206
+ stepRetiredRoleWords(out, receipts);
192
207
  stepModelsDefault(out, receipts);
193
208
  stepUrlFragments(out, receipts);
194
209
  stepWorkers6x(out, receipts);
@@ -675,6 +690,45 @@ function stepAtMention(out, receipts) {
675
690
  out.rosters = { ...rosters, rosters: next };
676
691
  }
677
692
  }
693
+ // ── SS-M8:退役的角色词 ────────────────────────────────────────────────────────────────────────────────
694
+ /** 一个 `roles` 图上的退役角色词键删掉(值不论 —— 没有角色读它);逐键一张 `dropped-inert` 收据。返回改后的图或 `undefined`(没改 / 不是对象:判形是读者的事)。 */
695
+ function withoutRetiredRoles(roles, document, at, receipts) {
696
+ if (!isPlainObject(roles))
697
+ return undefined;
698
+ const present = RETIRED_ROLE_WORDS.filter((w) => Object.prototype.hasOwnProperty.call(roles, w));
699
+ if (present.length === 0)
700
+ return undefined;
701
+ let next = roles;
702
+ for (const w of present) {
703
+ next = without(next, w);
704
+ receipts.push(receipt("SS-M8", document, [...at, "roles", w], "dropped-inert"));
705
+ }
706
+ return next;
707
+ }
708
+ function stepRetiredRoleWords(out, receipts) {
709
+ const models = out.models;
710
+ if (isPlainObject(models)) {
711
+ const roles = withoutRetiredRoles(models.roles, "models", [], receipts);
712
+ if (roles !== undefined)
713
+ out.models = { ...models, roles };
714
+ }
715
+ const rosters = out.rosters;
716
+ if (isPlainObject(rosters) && Array.isArray(rosters.rosters)) {
717
+ const rows = rosters.rosters;
718
+ let next;
719
+ rows.forEach((row, index) => {
720
+ if (!isPlainObject(row))
721
+ return;
722
+ const roles = withoutRetiredRoles(row.roles, "rosters", ["rosters", index], receipts);
723
+ if (roles === undefined)
724
+ return;
725
+ next ??= [...rows];
726
+ next[index] = { ...row, roles };
727
+ });
728
+ if (next !== undefined)
729
+ out.rosters = { ...rosters, rosters: next };
730
+ }
731
+ }
678
732
  // ── SS-M9:models.default → roles.default ──────────────────────────────────────────────────────────────
679
733
  /** 迁移前「缺省模型是谁」的生效读法 = server 的缺省解析以结构键 `default` 为第一级,主任务真跑它(N-7 两答复现:`default` = A、`roles.default`
680
734
  * = B 时主任务跑 A,core 的 `default` 角色解到 B)。所以并过去的是 `default` 的值:`roles.default` 缺席 ⇒ 搬入;同名 ⇒ 搬入(重复的那一份
@@ -13,7 +13,7 @@
13
13
  import type { AtMention, EffectiveConfig, EffectiveWire, ModelRole, RoleTarget, RosterEntry } from "./types.js";
14
14
  import { type RefIssue } from "./cross-domain.js";
15
15
  /** primaryModel 标记喂的角色族(§3)。 */
16
- export declare const ROSTER_PRIMARY_ROLES: readonly ["default", "team", "synthesize"];
16
+ export declare const ROSTER_PRIMARY_ROLES: readonly ["default"];
17
17
  /** cheapModel 标记喂的角色族(§3)。 */
18
18
  export declare const ROSTER_CHEAP_ROLES: readonly ["subagent", "summarize"];
19
19
  /** 把 roster 的主/cheap 角色标记派生成 roles 片段(PURE)。显式 roster.roles 逐角色盖过它;它盖过全局
@@ -2,12 +2,12 @@ import { stableHash } from "./hash.js";
2
2
  import { danglingRefs } from "./cross-domain.js";
3
3
  import { toSkillsManifest } from "./skills-manifest.js";
4
4
  // ── 主/cheap 角色标记语义(0.6.3, E3)────────────────────────────────
5
- // service 现有 ModelRoles 口径:default/team/synthesize→主、subagent/summarize→cheap。1.8.0 起 `classifier`/`consolidate`
5
+ // service 现有 ModelRoles 口径:default→主(12.0.0 起唯一;`team` / `synthesize` 随 core 7.49.0 删)、subagent/summarize→cheap。1.8.0 起 `classifier`/`consolidate`
6
6
  // 同 `verifier` 一样**不派生**(归 cheap 会盖掉 models.tiers 档位与 core 兜底链)。`verifier` 刻意
7
7
  // 不派生(unset→core 的 default 兜底链,与 service 语义一致)。常量导出=单一语义源,service 收敛小单与
8
8
  // web-admin 表单同源引用,别第二份。
9
9
  /** primaryModel 标记喂的角色族(§3)。 */
10
- export const ROSTER_PRIMARY_ROLES = ["default", "team", "synthesize"];
10
+ export const ROSTER_PRIMARY_ROLES = ["default"];
11
11
  /** cheapModel 标记喂的角色族(§3)。 */
12
12
  export const ROSTER_CHEAP_ROLES = ["subagent", "summarize"];
13
13
  /** 把 roster 的主/cheap 角色标记派生成 roles 片段(PURE)。显式 roster.roles 逐角色盖过它;它盖过全局
@@ -86,7 +86,7 @@ export function resolveEffectiveForWorker(eff, worker) {
86
86
  // `{select}`-form (capability-based) roles name no model → kept.
87
87
  // Per-role merge (roster overrides global PER ROLE) — NOT a whole-map replace. The types promise "unset
88
88
  // roster roles fall back to global models.roles per role"; a whole-map `??` silently dropped the global
89
- // `subagent`/`team`/… when a roster set only `default`. Round-3 fix. Then drop any dangling {model}.
89
+ // `subagent`/`summarize`/… when a roster set only `default`. Round-3 fix. Then drop any dangling {model}.
90
90
  // 0.6.3: marker-derived roles (primaryModel/cheapModel semantics) sit BETWEEN global and explicit
91
91
  // roster.roles — global < markers < explicit per-role. Markers are subset-validated at PUT; a stale marker
92
92
  // after a catalog change still falls through the dangling-{model} drop below (defence-in-depth).
@@ -0,0 +1,5 @@
1
+ /** 退役的角色词(闭集;逐字)。 */
2
+ export declare const RETIRED_ROLE_WORDS: readonly ["team", "synthesize"];
3
+ /** 一组被判官拒下的角色键 / 角色词里若有退役词 ⇒ 那一句(点名退役词与 SS-M8;同组里别的不认识的词也点名,一个不漏);没有 ⇒ `undefined`
4
+ * (交回 zod 自己的句子)。`ModelRole` 的判句(一个词)与 `roles` 图的未知键判句(一组键)都调这一只。 */
5
+ export declare function retiredRoleWordsRefusal(words: readonly unknown[]): string | undefined;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):12.0.0 退役的**角色词**墓碑 —— 引擎 core 7.49.0 把 `team` / `synthesize` 从角色表里删了(对未知角色词响亮拒),
3
+ * 本包的 `ModelRole` 随之收成 6 词。墓碑是**拒**,不是第二种读法:判官遇到这两个词不把它们读成任何角色,只说「这是一份没迁的旧形」以及去哪儿迁
4
+ * (一次性迁移 SS-M8,`./one-time-migrations`)。一处写词、一处写句:`ModelRole` 的判句、`roles` 图的未知键判句与 SS-M8 的改写步都读这里。
5
+ * 迁移族按登记表的窗整族删,墓碑留下 —— 到那时句子里的迁移指路由删码那一车去掉(登记表 SS-M8 行的 `removal`)。
6
+ */
7
+ import { retiredFormSentence } from "./retired-form.js";
8
+ /** 退役的角色词(闭集;逐字)。 */
9
+ export const RETIRED_ROLE_WORDS = ["team", "synthesize"];
10
+ const ROLE_WORDS_RETIRED = {
11
+ retiredIn: "12.0.0",
12
+ now: "no role reads it (core 7.49.0 removed the role and refuses the word)",
13
+ migration: "SS-M8",
14
+ };
15
+ const isRetiredRoleWord = (w) => RETIRED_ROLE_WORDS.some((r) => r === w);
16
+ /** 一组被判官拒下的角色键 / 角色词里若有退役词 ⇒ 那一句(点名退役词与 SS-M8;同组里别的不认识的词也点名,一个不漏);没有 ⇒ `undefined`
17
+ * (交回 zod 自己的句子)。`ModelRole` 的判句(一个词)与 `roles` 图的未知键判句(一组键)都调这一只。 */
18
+ export function retiredRoleWordsRefusal(words) {
19
+ const retired = RETIRED_ROLE_WORDS.filter((r) => words.includes(r));
20
+ if (retired.length === 0)
21
+ return undefined;
22
+ const others = words.filter((w) => !isRetiredRoleWord(w)).map((w) => JSON.stringify(String(w)));
23
+ const head = others.length === 0 ? "" : `unrecognized role ${others.length === 1 ? "word" : "words"} ${others.join(", ")}; `;
24
+ return head + retiredFormSentence(`role word ${retired.join(" / ")}`, ROLE_WORDS_RETIRED);
25
+ }
@@ -53,8 +53,8 @@
53
53
  import { z } from "zod";
54
54
  /** How values from different layers/principals combine for a category. */
55
55
  export declare const MergeShape: z.ZodEnum<{
56
- concat: "concat";
57
56
  replace: "replace";
57
+ concat: "concat";
58
58
  denyFirst: "denyFirst";
59
59
  runAll: "runAll";
60
60
  overrideByName: "overrideByName";
@@ -185,8 +185,8 @@ export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
185
185
  export type TightenRule = z.infer<typeof TightenRule>;
186
186
  export declare const MergeCategorySpec: z.ZodObject<{
187
187
  mergeShape: z.ZodEnum<{
188
- concat: "concat";
189
188
  replace: "replace";
189
+ concat: "concat";
190
190
  denyFirst: "denyFirst";
191
191
  runAll: "runAll";
192
192
  overrideByName: "overrideByName";
package/dist/types.d.ts CHANGED
@@ -204,8 +204,6 @@ export declare const ModelRole: z.ZodEnum<{
204
204
  default: "default";
205
205
  summarize: "summarize";
206
206
  subagent: "subagent";
207
- team: "team";
208
- synthesize: "synthesize";
209
207
  verifier: "verifier";
210
208
  classifier: "classifier";
211
209
  consolidate: "consolidate";
@@ -318,8 +316,6 @@ export declare const ModelsConfig: z.ZodObject<{
318
316
  default: "default";
319
317
  summarize: "summarize";
320
318
  subagent: "subagent";
321
- team: "team";
322
- synthesize: "synthesize";
323
319
  verifier: "verifier";
324
320
  classifier: "classifier";
325
321
  consolidate: "consolidate";
@@ -375,8 +371,6 @@ export declare const RosterEntry: z.ZodObject<{
375
371
  default: "default";
376
372
  summarize: "summarize";
377
373
  subagent: "subagent";
378
- team: "team";
379
- synthesize: "synthesize";
380
374
  verifier: "verifier";
381
375
  classifier: "classifier";
382
376
  consolidate: "consolidate";
@@ -421,8 +415,6 @@ export declare const RostersConfig: z.ZodObject<{
421
415
  default: "default";
422
416
  summarize: "summarize";
423
417
  subagent: "subagent";
424
- team: "team";
425
- synthesize: "synthesize";
426
418
  verifier: "verifier";
427
419
  classifier: "classifier";
428
420
  consolidate: "consolidate";
@@ -2334,8 +2326,6 @@ export declare const DOMAIN_SCHEMAS: {
2334
2326
  default: "default";
2335
2327
  summarize: "summarize";
2336
2328
  subagent: "subagent";
2337
- team: "team";
2338
- synthesize: "synthesize";
2339
2329
  verifier: "verifier";
2340
2330
  classifier: "classifier";
2341
2331
  consolidate: "consolidate";
@@ -2391,8 +2381,6 @@ export declare const DOMAIN_SCHEMAS: {
2391
2381
  default: "default";
2392
2382
  summarize: "summarize";
2393
2383
  subagent: "subagent";
2394
- team: "team";
2395
- synthesize: "synthesize";
2396
2384
  verifier: "verifier";
2397
2385
  classifier: "classifier";
2398
2386
  consolidate: "consolidate";
package/dist/types.js CHANGED
@@ -30,6 +30,7 @@ import { Sha256Digest } from "./sha256.js"; // the one `sha256:<hex>` digest for
30
30
  import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS, retiredLaneWordRefusal } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
31
31
  import { LANE_ENV_OWNER, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
32
32
  import { migrationSignpost, retiredFormSentence, retiredKey } from "./retired-form.js"; // 11.0.0: the ONE retired-form sentence + key tombstone (package-private)
33
+ import { retiredRoleWordsRefusal } from "./retired-role-words.js"; // 12.0.0: the retired role-word tombstone (package-private; SS-M8)
33
34
  import { buildQuotedIssue, fixed } from "./quoted-issue.js"; // THE minting point of custom issues (package-private)
34
35
  import { AbsoluteHostPath } from "./host-path.js"; // the one host-path shape judge (a task's cwd and a project's folder / file entries)
35
36
  import { RFC3339_DATE_TIME } from "./rfc3339.js"; // the package's one RFC 3339 date-time judge (package-private)
@@ -550,20 +551,31 @@ export const ModelEntry = z.object({
550
551
  // `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
551
552
  // (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
552
553
  // semantics per core: a roster that doesn't set `verifier` uses `default`.
553
- // 1.8.0(消费方壳侧真案):对齐 core `ModelRole`(9 词)里**有真消费方**的两个缺席角色 ——
554
- // `classifier` = auto 模式权限分类器(core FALLBACK classifier→summarize→default;tier 缺省 flash);此前 settings
555
- // 写 `roles.classifier` 被本闭集拒,用户想单独钉分类器模型只能连带 summarize。
554
+ // 1.8.0(消费方壳侧真案):对齐 core 角色表里**有真消费方**的两个角色 ——
555
+ // `classifier` = auto 模式权限分类器(core FALLBACK classifier→summarize→default;tier 缺省 flash);
556
556
  // `consolidate` = 记忆整理(core FALLBACK consolidate→summarize)。
557
557
  // 两个词都**不进** roster 标记族(resolve-roster.ts):显式归 cheap 会让 server 把它钉死到 cheapRef,盖掉用户
558
558
  // `models.tiers.flash` 的档位与 core 兜底链 —— 新角色「不派生、走 core 兜底、只由 settings 显式钉」。
559
- // `advisor`(teacher mode)仍 deliberately NOT added: no consumer, no key —— 谁消费谁来加。
560
- // 子集关系由消费方服务端的型面对账钉(编译期 `satisfies`)钉住(本仓不依赖 core)。
561
- export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "synthesize", "verifier", "classifier", "consolidate"]);
559
+ // 12.0.0(硬破坏):`team` / `synthesize` 随 core 7.49.0 删掉(引擎对未知角色词响亮拒)—— 零别名、零双读;存着的这两个键由一次性迁移 SS-M8 删,
560
+ // 迁移前到达判官的旧词拒句点名它(墓碑在 `./retired-role-words`)。`advisor`(teacher mode)本包从未收(没有消费方)。
561
+ // 子集关系(本表 ⊆ 引擎角色表)由消费方服务端的型面对账钉住(本仓不依赖 core;dev 期的漂移门 ② 对拍 devDependency 那一版引擎)。
562
+ export const ModelRole = z.enum(["default", "summarize", "subagent", "verifier", "classifier", "consolidate"], {
563
+ error: (iss) => (iss.code === "invalid_value" ? retiredRoleWordsRefusal([iss.input]) : undefined),
564
+ });
562
565
  /** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
563
566
  export const RoleTarget = z.union([
564
567
  z.object({ model: z.string().min(1) }),
565
568
  z.object({ select: z.object({ tier: ModelTier.optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
566
569
  ]);
570
+ /** 角色 → 目标的图(`models.roles` 与 `rosters[].roles` 同一只)。键不认识 ⇒ zod 的「未知键」句;键里有 12.0.0 退役的角色词 ⇒ 那一句点名
571
+ * 一次性迁移 SS-M8(同组别的未知键一并点名)。 */
572
+ const RoleMap = z.partialRecord(ModelRole, RoleTarget, {
573
+ // 枚举键的 record 在运行期以 `unrecognized_keys`(带 `keys`)报未知键,zod 的型面只列了 invalid_type / invalid_key —— 按运行期形读(钉:role-words-12.0.0)。
574
+ error: (iss) => {
575
+ const raw = iss;
576
+ return raw.code === "unrecognized_keys" && Array.isArray(raw.keys) ? retiredRoleWordsRefusal(raw.keys) : undefined;
577
+ },
578
+ });
567
579
  /** {@link AtMention} 的模式闭集:`all` = 每个启用的模型都可 `@`;`none` = 一个都不许;`only` = 只许 `models` 里点名的那几个。 */
568
580
  export const AT_MENTION_MODES = ["all", "none", "only"];
569
581
  /** 「`@` 能点名哪些模型」(11.0.0,取代 `atModelAllowlist`)。义由 `mode` 说,不由数组空不空说:旧形一个数组靠「缺席 / 空 / 非空」
@@ -609,7 +621,7 @@ export const ModelsConfig = z
609
621
  * the main task ran the first and the core `default` role resolved the second). Which catalog entry is the default is
610
622
  * `resolveCatalogDefaultModel` (`./default-model`; a `{select}` default is handed back as-is for the engine to resolve). A stored
611
623
  * `default` key is moved here by the one-time migration SS-M9. */
612
- roles: z.partialRecord(ModelRole, RoleTarget).prefault({}),
624
+ roles: RoleMap.prefault({}),
613
625
  /** Which model names an end user may `@`-mention({@link AtMention});缺省 `{mode:"all"}`。 */
614
626
  atMention: AtMention.prefault({ mode: "all" }),
615
627
  /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
@@ -675,7 +687,7 @@ export const RosterEntry = z.object({
675
687
  models: z.array(z.string().min(1)).prefault([]),
676
688
  /**
677
689
  * 主模型角色标记(0.6.3, E3)——catalog model NAME。worker.model 废除后 service 从 roster
678
- * 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
690
+ * 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`(12.0.0 起唯一;`team` / `synthesize` 随 core 7.49.0 删)、
679
691
  * cheap 喂 `subagent`/`summarize`(service 现有 ModelRoles 口径;`verifier`/`classifier`/`consolidate` 不派生——unset 走 core 兜底链)。
680
692
  * 显式 `roles` **逐角色**优先于标记派生;标记优先于全局 models.roles。additive:不设=行为与 0.6.2 全等。
681
693
  */
@@ -683,7 +695,7 @@ export const RosterEntry = z.object({
683
695
  /** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
684
696
  cheapModel: z.string().min(1).optional(),
685
697
  /** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
686
- roles: z.partialRecord(ModelRole, RoleTarget).optional(),
698
+ roles: RoleMap.optional(),
687
699
  /** `@` 点名覆盖({@link AtMention});缺席 = 继承全局 `models.atMention`。 */
688
700
  atMention: AtMention.optional(),
689
701
  /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):原子写器(`./atomic-write`)写失败的**唯一一句** —— 按失败落在哪一步、errno 是什么分句,
3
+ * 句尾给那一步对应的出路。写器有两步:在目标所在目录里建临时文件(`create`,连同恢复沿用的模式)、`rename` 盖过目标(`replace`)。
4
+ * 改前一句话包打天下(「cannot write <文件>: <Node 的错误句> … fix the cause」):目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
5
+ * 而可写目录里的只读文件照常写得进 —— 句子把人指向了错的那一个。命令行(`./cli`)认这只错误类型、原样印它的句子。
6
+ */
7
+ /** 原子写的两步(闭集)。 */
8
+ export type AtomicWriteStep = "create" | "replace";
9
+ /** 一次写失败的句子(纯函数)。认得的 errno 给对应出路;别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。 */
10
+ export declare function atomicWriteFailureSentence(step: AtomicWriteStep, dir: string, file: string, cause: unknown): string;
11
+ /** 原子写器抛的错:句子见 {@link atomicWriteFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
12
+ export declare class AtomicWriteFailure extends Error {
13
+ readonly step: AtomicWriteStep;
14
+ readonly code: string | undefined;
15
+ constructor(step: AtomicWriteStep, dir: string, file: string, cause: unknown);
16
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):原子写器(`./atomic-write`)写失败的**唯一一句** —— 按失败落在哪一步、errno 是什么分句,
3
+ * 句尾给那一步对应的出路。写器有两步:在目标所在目录里建临时文件(`create`,连同恢复沿用的模式)、`rename` 盖过目标(`replace`)。
4
+ * 改前一句话包打天下(「cannot write <文件>: <Node 的错误句> … fix the cause」):目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
5
+ * 而可写目录里的只读文件照常写得进 —— 句子把人指向了错的那一个。命令行(`./cli`)认这只错误类型、原样印它的句子。
6
+ */
7
+ /** `create` 步里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
8
+ const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
9
+ /** `replace` 步里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 让文件可被替换。 */
10
+ const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
11
+ const errnoOf = (e) => {
12
+ const code = e?.code;
13
+ return typeof code === "string" ? code : undefined;
14
+ };
15
+ const messageOf = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s*\n\s*/g, " ");
16
+ /** 一次写失败的句子(纯函数)。认得的 errno 给对应出路;别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。 */
17
+ export function atomicWriteFailureSentence(step, dir, file, cause) {
18
+ const code = errnoOf(cause);
19
+ const tag = code === undefined ? "" : ` (${code})`;
20
+ switch (step) {
21
+ case "create":
22
+ return code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? `cannot write into directory ${dir}${tag}: make the directory writable` : `cannot write into directory ${dir}${tag}: ${messageOf(cause)}`;
23
+ case "replace":
24
+ return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
25
+ ? `cannot replace ${file}${tag}: make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it`
26
+ : `cannot replace ${file}${tag}: ${messageOf(cause)}`;
27
+ default: {
28
+ const unhandled = step;
29
+ throw new Error(`atomicWriteFailureSentence: unhandled step ${String(unhandled)}`);
30
+ }
31
+ }
32
+ }
33
+ /** 原子写器抛的错:句子见 {@link atomicWriteFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
34
+ export class AtomicWriteFailure extends Error {
35
+ step;
36
+ code;
37
+ constructor(step, dir, file, cause) {
38
+ super(atomicWriteFailureSentence(step, dir, file, cause), { cause });
39
+ this.name = "AtomicWriteFailure";
40
+ this.step = step;
41
+ this.code = errnoOf(cause);
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "11.3.0",
3
+ "version": "12.0.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-web-admin, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",