|
42 | 42 | * at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the |
43 | 43 | * schema to an enum (a breaking authoring change ADR-0063 already walked |
44 | 44 | * back once). |
| 45 | + * |
| 46 | + * ## Why the value limb no longer reads the same roster (issue #14461) |
| 47 | + * |
| 48 | + * `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb |
| 49 | + * meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact |
| 50 | + * spelling `skills/objectstack-ai` tells authors is "not vocabulary". The |
| 51 | + * platform then taught it from its own only live example: `studio.app.ts` |
| 52 | + * pinned the alias. So an AI author copying the one working example in the |
| 53 | + * repo wrote the forbidden spelling and this rule waved it through — the |
| 54 | + * silent-tolerance shape ADR-0078 exists to close, committed by the gate |
| 55 | + * itself. |
| 56 | + * |
| 57 | + * The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and |
| 58 | + * SPLITS the two limbs' rosters, keeping both of #6041's operative decisions |
| 59 | + * intact (warning tier, no Zod enum): |
| 60 | + * |
| 61 | + * - the DECLARATION limb still reads all four names. Its question is "does |
| 62 | + * this record shadow a platform record?", and declaring `metadata_assistant` |
| 63 | + * shadows `build` through the alias exactly as declaring `build` does. That |
| 64 | + * judgement is unchanged. |
| 65 | + * - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias |
| 66 | + * gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}). |
| 67 | + * Its question is "is this the right thing to WRITE?", and the answer for |
| 68 | + * an alias is no even though it resolves. |
| 69 | + * |
| 70 | + * The alias limb stays `warning`, not `error`, and for a sharper reason than |
| 71 | + * the roster limb: an aliased pin is not broken. It resolves, the app gets the |
| 72 | + * agent it meant, and nothing a user can see is wrong — which is precisely why |
| 73 | + * the signal has to be an authoring-time nudge rather than a build break. |
45 | 74 | */ |
46 | 75 |
|
47 | 76 | export const AGENT_AUTHORING_WITHDRAWN = 'agent-authoring-withdrawn'; |
48 | 77 |
|
49 | 78 | /** `app.defaultAgent` names something outside the platform agent roster. */ |
50 | 79 | export const DEFAULT_AGENT_OUTSIDE_ROSTER = 'default-agent-outside-roster'; |
51 | 80 |
|
| 81 | +/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */ |
| 82 | +export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias'; |
| 83 | + |
52 | 84 | export type AiAgentAuthoringSeverity = 'error' | 'warning'; |
53 | 85 |
|
54 | 86 | export interface AiAgentAuthoringFinding { |
@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined { |
81 | 113 | } |
82 | 114 |
|
83 | 115 | /** |
84 | | - * The two platform agent ids (`ask`, `build`) plus their two legacy aliases |
85 | | - * (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the |
86 | | - * cloud alias registry — ADR-0063 §2). A stack that re-declares any of these |
87 | | - * four names is doing something different from inventing a custom persona |
88 | | - * (it is shadowing a platform record, directly or through its alias), so it |
89 | | - * gets its own wording. |
| 116 | + * The two platform agent ids — the only two names that are AUTHORING |
| 117 | + * vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value |
| 118 | + * limb judges against. |
90 | 119 | */ |
91 | | -const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']); |
| 120 | +const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build']; |
| 121 | + |
| 122 | +/** |
| 123 | + * Retired spellings → the canonical id each resolves to (`data_chat` → `ask`, |
| 124 | + * `metadata_assistant` → `build`), registered one-way in the cloud alias |
| 125 | + * registry at plugin init — ADR-0063 §2. Resolution-only: they are not |
| 126 | + * separate records, and the agent catalog shows each agent once under its |
| 127 | + * canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s; |
| 128 | + * never for new authoring (#14461). |
| 129 | + */ |
| 130 | +const LEGACY_AGENT_ALIASES = new Map<string, string>([ |
| 131 | + ['data_chat', 'ask'], |
| 132 | + ['metadata_assistant', 'build'], |
| 133 | +]); |
| 134 | + |
| 135 | +/** |
| 136 | + * Every name that refers to a platform agent, canonically or through its |
| 137 | + * alias. A stack that re-declares any of these four is doing something |
| 138 | + * different from inventing a custom persona (it is shadowing a platform |
| 139 | + * record, directly or through its alias), so it gets its own wording. |
| 140 | + * |
| 141 | + * Deliberately NOT the roster the value limb reads — see the docblock's |
| 142 | + * "#14461" section for why the two questions take different tables. |
| 143 | + */ |
| 144 | +const PLATFORM_AGENT_NAMES = new Set<string>([ |
| 145 | + ...CANONICAL_AGENT_NAMES, |
| 146 | + ...LEGACY_AGENT_ALIASES.keys(), |
| 147 | +]); |
92 | 148 |
|
93 | 149 | /** |
94 | 150 | * Flag every agent declared in a stack. Returns findings (empty = clean, |
@@ -132,25 +188,51 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding |
132 | 188 | }); |
133 | 189 | } |
134 | 190 |
|
135 | | - const roster = [...PLATFORM_AGENT_NAMES].join(', '); |
| 191 | + const roster = CANONICAL_AGENT_NAMES.join(', '); |
136 | 192 | const apps = asArray(stack.apps); |
137 | 193 | for (let appIdx = 0; appIdx < apps.length; appIdx++) { |
138 | 194 | const app = apps[appIdx]; |
139 | 195 | const defaultAgent = strName(app.defaultAgent); |
140 | | - if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue; |
| 196 | + if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue; |
141 | 197 |
|
142 | 198 | const appName = strName(app.name) ?? `#${appIdx}`; |
| 199 | + const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent); |
| 200 | + |
| 201 | + // [#14461] Two different defects share this slot, and collapsing them |
| 202 | + // would misdescribe both: an alias RESOLVES (the app gets the agent it |
| 203 | + // meant) and an unknown name does NOT (the pin is inert). Separate rule |
| 204 | + // ids so a consumer can act on them separately. |
| 205 | + if (canonical) { |
| 206 | + findings.push({ |
| 207 | + severity: 'warning', |
| 208 | + rule: DEFAULT_AGENT_LEGACY_ALIAS, |
| 209 | + where: `app "${appName}".defaultAgent`, |
| 210 | + path: `apps[${appIdx}].defaultAgent`, |
| 211 | + message: |
| 212 | + `app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` + |
| 213 | + `platform agent "${canonical}". It still resolves — the alias registry maps legacy ` + |
| 214 | + `names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` + |
| 215 | + `so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` + |
| 216 | + `also the weaker pin: resolution depends on the owning package's in-process alias ` + |
| 217 | + `registration having run, which the canonical id does not.`, |
| 218 | + hint: |
| 219 | + `Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` + |
| 220 | + `authoring vocabulary — always author the canonical id (${roster}).`, |
| 221 | + }); |
| 222 | + continue; |
| 223 | + } |
| 224 | + |
143 | 225 | findings.push({ |
144 | 226 | severity: 'warning', |
145 | 227 | rule: DEFAULT_AGENT_OUTSIDE_ROSTER, |
146 | 228 | where: `app "${appName}".defaultAgent`, |
147 | 229 | path: `apps[${appIdx}].defaultAgent`, |
148 | 230 | message: |
149 | | - `app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` + |
150 | | - `platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` + |
151 | | - `and resolves this key against them and their legacy aliases only — an unrecognized ` + |
152 | | - `name is not rejected, it silently falls back to the platform default at runtime, so ` + |
153 | | - `the pin has no effect and the value drifts from what actually serves the app.`, |
| 231 | + `app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` + |
| 232 | + `agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` + |
| 233 | + `key against them and their legacy aliases only — an unrecognized name is not rejected, ` + |
| 234 | + `it silently falls back to the platform default at runtime, so the pin has no effect and ` + |
| 235 | + `the value drifts from what actually serves the app.`, |
154 | 236 | hint: |
155 | 237 | `Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` + |
156 | 238 | `dedicated persona or capability, express it as skills instead — they attach to "ask" ` + |
|
0 commit comments