@shiftleftpt/sbd-toe-mcp 0.20.0-beta.20 → 0.20.0-beta.22

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.
@@ -33,7 +33,7 @@ import { servedKgReleaseTag } from "../version-info.js";
33
33
  import { expandQueryWithAliases } from "../backend/semantic-index-gateway.js";
34
34
  import { requirementCategoryOf } from "../serving/requirement-id.js";
35
35
  import { prepareCodegenAffordances } from "../serving/affordances.js";
36
- import { runSelectionWithActivation } from "../serving/selection.js";
36
+ import { runSelectionWithActivation, normalizeDeclaredTechnologies, stackTokensFromVocabulary } from "../serving/selection.js";
37
37
  import { REQUIREMENT_CEILING_BY_DETAIL, COST_PER_REQ_TK, BASE_TK, PAYLOAD_PROMISE_TK, projectedCostTk } from "../serving/payload-ceilings.js";
38
38
  const EVIDENCE_PATTERN_CAP = 25;
39
39
  /**
@@ -275,7 +275,24 @@ const TASK_TERM_TO_CONCERNS = [
275
275
  * categories. Keys are concerns added by this module. The categories map to
276
276
  * existing runtime v0 categories so the v0 requirement filter still works.
277
277
  */
278
- const CONCERN_TO_V0_CATEGORIES_SUPPLEMENT = {
278
+ /**
279
+ * Declared context activators (G-mp1a/D3). Module scope + exported since
280
+ * 0.20.0-beta.21 (declarative-first): `sbd://toe/activation-vocabulary` publishes
281
+ * them — the vocabulary IS the contract, so it is derived from these tables and
282
+ * never restated by hand.
283
+ */
284
+ export const EXPOSURE_CONCERNS = {
285
+ internal: ["auth", "logging"],
286
+ authenticated: ["auth", "logging"],
287
+ public: ["auth", "logging", "api", "validation", "architecture"]
288
+ };
289
+ export const SENSITIVITY_CONCERNS = {
290
+ // v1.8.0: personal/regulated data now also activates the PRI catalogue (privacy).
291
+ personal: ["encryption", "validation", "logging", "privacy"],
292
+ regulated: ["encryption", "validation", "logging", "privacy"],
293
+ secrets: ["secrets"]
294
+ };
295
+ export const CONCERN_TO_V0_CATEGORIES_SUPPLEMENT = {
279
296
  // Existing concerns intentionally left empty — fall back to ontology.concernsMap.
280
297
  auth: [],
281
298
  logging: [],
@@ -589,7 +606,8 @@ function recordActivation(trace, concerns, scores, rejected, entry, targetConcer
589
606
  trace.push(entry);
590
607
  }
591
608
  }
592
- export function activate(input) {
609
+ export function activate(input, options = {}) {
610
+ const declaredOnly = options.declaredOnly ?? false;
593
611
  const trace = [];
594
612
  const rejected = [];
595
613
  const notes = [];
@@ -616,110 +634,118 @@ export function activate(input) {
616
634
  reason: `Concern '${unknown}' is not in the WP5/WP6 lexicon (${VALID_CONCERNS.join(", ")}).`
617
635
  });
618
636
  }
619
- // 2) Alias expansion + direct task-term matches.
620
- const { expanded, appliedAliases } = expandTaskText(input.taskLower);
621
- for (const alias of appliedAliases) {
622
- notes.push(`alias_expansion: '${alias.pt}' -> [${alias.en.join(", ")}]`);
623
- }
624
- // 2a) Compound phrases (run first — they encode canonical multi-domain asks).
625
- // Whole-word match prevents accidental hits inside larger tokens.
626
- for (const [phrase, mapped] of COMPOUND_TERM_TO_CONCERNS) {
627
- if (!taskMatchesKeyword(expanded, phrase))
628
- continue;
629
- for (const concern of mapped) {
630
- recordActivation(trace, concerns, concernScores, rejected, {
631
- source: "compound_term",
632
- produced: concern,
633
- trigger: phrase,
634
- score: 0.7,
635
- confidence: "semantic",
636
- reason: `Compound phrase '${phrase}' activates ${concern}.`
637
- }, concern, { capDuplicates: true });
637
+ // 0.20.0-beta.21 — DECLARATIVO PRIMEIRO: tudo o que se segue até ao bloco 4 é
638
+ // INFERÊNCIA SOBRE PROSA/PATHS (termos da tarefa, aliases, compostos, homónimo
639
+ // da imagem, intenções, e as heurísticas de NOME de ficheiro). No caminho
640
+ // declarativo (default nesta linha) NÃO corre: o servidor responde ao que lhe
641
+ // declararam. Fica disponível em mode="discover" — instrumento de investigação
642
+ // (oráculo histórico + estudo de paráfrase), marcado exploratório na resposta.
643
+ if (!declaredOnly) {
644
+ // 2) Alias expansion + direct task-term matches.
645
+ const { expanded, appliedAliases } = expandTaskText(input.taskLower);
646
+ for (const alias of appliedAliases) {
647
+ notes.push(`alias_expansion: '${alias.pt}' -> [${alias.en.join(", ")}]`);
638
648
  }
639
- }
640
- // 2b) Single-token task terms. Whole-word matching prevents false positives
641
- // like `test` inside `latest` or `log` inside `logical`.
642
- for (const [term, mapped] of TASK_TERM_TO_CONCERNS) {
643
- if (!taskMatchesKeyword(expanded, term))
644
- continue;
645
- const viaAlias = !taskMatchesKeyword(input.taskLower, term) &&
646
- appliedAliases.some((alias) => alias.en.includes(term));
647
- for (const concern of mapped) {
648
- recordActivation(trace, concerns, concernScores, rejected, {
649
- source: viaAlias ? "alias_expansion" : "task_term",
650
- produced: concern,
651
- trigger: term,
652
- score: viaAlias ? 0.6 : 0.8,
653
- confidence: viaAlias ? "semantic" : "deterministic",
654
- reason: viaAlias
655
- ? `Task text matches '${term}' after PT/EN alias expansion.`
656
- : `Task text contains '${term}'.`
657
- }, concern, { capDuplicates: true });
658
- }
659
- }
660
- // 2b-bis) R-image (vaga v1.8.0, 2026-08-31): "image"/"imagem" é homónimo —
661
- // imagem de container vs ficheiro de imagem (finding do replay DualGauge).
662
- // Desambiguação DECLARADA por contexto: image+docker/registry/container → sentido
663
- // container (deployment/distribution); image+file/upload/photo → FIL (files);
664
- // ambos os contextos → ambos; nenhum → sentido histórico (deployment/distribution).
665
- if (taskMatchesKeyword(expanded, "image")) {
666
- const containerCtx = /docker|registry|container|kubernetes|k8s|\boci\b/.test(expanded);
667
- const fileCtx = /upload|file|photo|picture|png|jpe?g|gif|avatar|galeria|gallery|perfil|profile/.test(expanded);
668
- const senses = [];
669
- if (fileCtx) {
670
- senses.push({ concern: "files", reason: "R-image: 'image' em contexto file/upload/photo → ficheiro de imagem (FIL)" });
649
+ // 2a) Compound phrases (run first — they encode canonical multi-domain asks).
650
+ // Whole-word match prevents accidental hits inside larger tokens.
651
+ for (const [phrase, mapped] of COMPOUND_TERM_TO_CONCERNS) {
652
+ if (!taskMatchesKeyword(expanded, phrase))
653
+ continue;
654
+ for (const concern of mapped) {
655
+ recordActivation(trace, concerns, concernScores, rejected, {
656
+ source: "compound_term",
657
+ produced: concern,
658
+ trigger: phrase,
659
+ score: 0.7,
660
+ confidence: "semantic",
661
+ reason: `Compound phrase '${phrase}' activates ${concern}.`
662
+ }, concern, { capDuplicates: true });
663
+ }
671
664
  }
672
- if (containerCtx || !fileCtx) {
673
- senses.push({ concern: "deployment", reason: containerCtx ? "R-image: 'image' em contexto docker/registry/container → imagem de container" : "R-image: 'image' sem contexto discriminante → sentido histórico (deployment)" }, { concern: "distribution", reason: containerCtx ? "R-image: 'image' em contexto docker/registry/container → distribuição de imagem" : "R-image: 'image' sem contexto discriminante → sentido histórico (distribution)" });
665
+ // 2b) Single-token task terms. Whole-word matching prevents false positives
666
+ // like `test` inside `latest` or `log` inside `logical`.
667
+ for (const [term, mapped] of TASK_TERM_TO_CONCERNS) {
668
+ if (!taskMatchesKeyword(expanded, term))
669
+ continue;
670
+ const viaAlias = !taskMatchesKeyword(input.taskLower, term) &&
671
+ appliedAliases.some((alias) => alias.en.includes(term));
672
+ for (const concern of mapped) {
673
+ recordActivation(trace, concerns, concernScores, rejected, {
674
+ source: viaAlias ? "alias_expansion" : "task_term",
675
+ produced: concern,
676
+ trigger: term,
677
+ score: viaAlias ? 0.6 : 0.8,
678
+ confidence: viaAlias ? "semantic" : "deterministic",
679
+ reason: viaAlias
680
+ ? `Task text matches '${term}' after PT/EN alias expansion.`
681
+ : `Task text contains '${term}'.`
682
+ }, concern, { capDuplicates: true });
683
+ }
674
684
  }
675
- for (const { concern, reason } of senses) {
676
- recordActivation(trace, concerns, concernScores, rejected, { source: "task_term", produced: concern, trigger: "image", score: 0.8, confidence: "deterministic", reason }, concern, { capDuplicates: true });
685
+ // 2b-bis) R-image (vaga v1.8.0, 2026-08-31): "image"/"imagem" é homónimo —
686
+ // imagem de container vs ficheiro de imagem (finding do replay DualGauge).
687
+ // Desambiguação DECLARADA por contexto: image+docker/registry/container → sentido
688
+ // container (deployment/distribution); image+file/upload/photo → FIL (files);
689
+ // ambos os contextos → ambos; nenhum → sentido histórico (deployment/distribution).
690
+ if (taskMatchesKeyword(expanded, "image")) {
691
+ const containerCtx = /docker|registry|container|kubernetes|k8s|\boci\b/.test(expanded);
692
+ const fileCtx = /upload|file|photo|picture|png|jpe?g|gif|avatar|galeria|gallery|perfil|profile/.test(expanded);
693
+ const senses = [];
694
+ if (fileCtx) {
695
+ senses.push({ concern: "files", reason: "R-image: 'image' em contexto file/upload/photo → ficheiro de imagem (FIL)" });
696
+ }
697
+ if (containerCtx || !fileCtx) {
698
+ senses.push({ concern: "deployment", reason: containerCtx ? "R-image: 'image' em contexto docker/registry/container → imagem de container" : "R-image: 'image' sem contexto discriminante → sentido histórico (deployment)" }, { concern: "distribution", reason: containerCtx ? "R-image: 'image' em contexto docker/registry/container → distribuição de imagem" : "R-image: 'image' sem contexto discriminante → sentido histórico (distribution)" });
699
+ }
700
+ for (const { concern, reason } of senses) {
701
+ recordActivation(trace, concerns, concernScores, rejected, { source: "task_term", produced: concern, trigger: "image", score: 0.8, confidence: "deterministic", reason }, concern, { capDuplicates: true });
702
+ }
677
703
  }
678
- }
679
- // 2c) Whole-word intent classification (codegen-specific; stricter than the
680
- // gateway's substring matcher to avoid PT/EN false positives).
681
- for (const intentEntry of CODEGEN_INTENTS) {
682
- const matchedKeyword = intentEntry.keywords.find((keyword) => taskMatchesKeyword(expanded, keyword));
683
- if (!matchedKeyword)
684
- continue;
685
- for (const concern of intentEntry.concerns) {
686
- recordActivation(trace, concerns, concernScores, rejected, {
687
- source: "intent_keyword",
688
- produced: concern,
689
- trigger: intentEntry.intent,
690
- score: 0.5,
691
- confidence: "semantic",
692
- reason: `Intent '${intentEntry.intent}' matched keyword '${matchedKeyword}'.`
693
- }, concern, { capDuplicates: false });
704
+ // 2c) Whole-word intent classification (codegen-specific; stricter than the
705
+ // gateway's substring matcher to avoid PT/EN false positives).
706
+ for (const intentEntry of CODEGEN_INTENTS) {
707
+ const matchedKeyword = intentEntry.keywords.find((keyword) => taskMatchesKeyword(expanded, keyword));
708
+ if (!matchedKeyword)
709
+ continue;
710
+ for (const concern of intentEntry.concerns) {
711
+ recordActivation(trace, concerns, concernScores, rejected, {
712
+ source: "intent_keyword",
713
+ produced: concern,
714
+ trigger: intentEntry.intent,
715
+ score: 0.5,
716
+ confidence: "semantic",
717
+ reason: `Intent '${intentEntry.intent}' matched keyword '${matchedKeyword}'.`
718
+ }, concern, { capDuplicates: false });
719
+ }
694
720
  }
695
- }
696
- // 3) Changed-file path heuristics.
697
- for (const file of input.changed_files) {
698
- const lower = file.toLowerCase();
699
- const fileHits = [];
700
- if (/route|router|controller|handler|endpoint/.test(lower))
701
- fileHits.push("api");
702
- if (/auth|session|jwt|login/.test(lower))
703
- fileHits.push("auth");
704
- if (/log|logger/.test(lower))
705
- fileHits.push("logging");
706
- if (/config|env|settings/.test(lower))
707
- fileHits.push("config");
708
- if (/secret|credential/.test(lower))
709
- fileHits.push("secrets");
710
- if (/test|spec/.test(lower))
711
- fileHits.push("testing");
712
- if (/dockerfile|docker-compose|k8s|kubernetes|terraform/.test(lower))
713
- fileHits.push("deployment");
714
- for (const concern of fileHits) {
715
- recordActivation(trace, concerns, concernScores, rejected, {
716
- source: "changed_file",
717
- produced: concern,
718
- trigger: file,
719
- score: 0.5,
720
- confidence: "semantic",
721
- reason: `Changed file path matches '${concern}' heuristics.`
722
- }, concern, { capDuplicates: true });
721
+ // 3) Changed-file path heuristics.
722
+ for (const file of input.changed_files) {
723
+ const lower = file.toLowerCase();
724
+ const fileHits = [];
725
+ if (/route|router|controller|handler|endpoint/.test(lower))
726
+ fileHits.push("api");
727
+ if (/auth|session|jwt|login/.test(lower))
728
+ fileHits.push("auth");
729
+ if (/log|logger/.test(lower))
730
+ fileHits.push("logging");
731
+ if (/config|env|settings/.test(lower))
732
+ fileHits.push("config");
733
+ if (/secret|credential/.test(lower))
734
+ fileHits.push("secrets");
735
+ if (/test|spec/.test(lower))
736
+ fileHits.push("testing");
737
+ if (/dockerfile|docker-compose|k8s|kubernetes|terraform/.test(lower))
738
+ fileHits.push("deployment");
739
+ for (const concern of fileHits) {
740
+ recordActivation(trace, concerns, concernScores, rejected, {
741
+ source: "changed_file",
742
+ produced: concern,
743
+ trigger: file,
744
+ score: 0.5,
745
+ confidence: "semantic",
746
+ reason: `Changed file path matches '${concern}' heuristics.`
747
+ }, concern, { capDuplicates: true });
748
+ }
723
749
  }
724
750
  }
725
751
  // 4) Slice families (ranked by max contributing concern score).
@@ -734,7 +760,11 @@ export function activate(input) {
734
760
  sliceFamilyScores.set(family, score);
735
761
  }
736
762
  trace.push({
737
- source: "task_term",
763
+ // 0.20.0-beta.22 (P2-A): isto NUNCA foi um termo da tarefa — é o mapeamento
764
+ // determinístico concern → slice family. A etiqueta `task_term` era órfã do
765
+ // motor lexical e aparecia mesmo com `task` vazio. Em `discover` o task_term
766
+ // legítimo (casamento de palavras) mantém-se; aqui a fonte diz o que é.
767
+ source: declaredOnly ? "concern_slice_mapping" : "task_term",
738
768
  produced: family,
739
769
  trigger: concern,
740
770
  score,
@@ -747,11 +777,6 @@ export function activate(input) {
747
777
  // rule (each with its own trace source), because the reference selection
748
778
  // semantics says an authenticated/public surface must be auditable and a
749
779
  // personal/regulated data context must carry crypto+masking+validation.
750
- const EXPOSURE_CONCERNS = {
751
- internal: ["auth", "logging"],
752
- authenticated: ["auth", "logging"],
753
- public: ["auth", "logging", "api", "validation", "architecture"]
754
- };
755
780
  if (input.exposure && EXPOSURE_CONCERNS[input.exposure]) {
756
781
  for (const concern of EXPOSURE_CONCERNS[input.exposure] ?? []) {
757
782
  recordActivation(trace, concerns, concernScores, rejected, {
@@ -764,12 +789,6 @@ export function activate(input) {
764
789
  }, concern, { capDuplicates: true });
765
790
  }
766
791
  }
767
- const SENSITIVITY_CONCERNS = {
768
- // v1.8.0: personal/regulated data now also activates the PRI catalogue (privacy).
769
- personal: ["encryption", "validation", "logging", "privacy"],
770
- regulated: ["encryption", "validation", "logging", "privacy"],
771
- secrets: ["secrets"]
772
- };
773
792
  if (input.data_sensitivity && SENSITIVITY_CONCERNS[input.data_sensitivity]) {
774
793
  for (const concern of SENSITIVITY_CONCERNS[input.data_sensitivity] ?? []) {
775
794
  recordActivation(trace, concerns, concernScores, rejected, {
@@ -782,6 +801,22 @@ export function activate(input) {
782
801
  }, concern, { capDuplicates: true });
783
802
  }
784
803
  }
804
+ // 5-pre) 0.20.0-beta.22 (P1-D): o `stack` é a ÚNICA leitura de texto que resta no
805
+ // caminho declarativo (token EXACTO de um conjunto fechado — normalizar o declarado).
806
+ // Deixava de fora o rasto: os capítulos apareciam sem que o auditor pudesse ver
807
+ // porquê. Agora cada token reconhecido emite a sua entrada.
808
+ if (declaredOnly && input.stack) {
809
+ for (const token of stackTokensFromVocabulary(input.stack)) {
810
+ trace.push({
811
+ source: "stack_token",
812
+ produced: token,
813
+ trigger: "stack",
814
+ score: 0.9,
815
+ confidence: "deterministic",
816
+ reason: `token exacto de \`technologies\` encontrado em \`stack\`: '${token}' (normalização de valor declarado; o texto livre à volta é ignorado)`
817
+ });
818
+ }
819
+ }
785
820
  // 5) Risk level (informational trace entry, no concern activation).
786
821
  if (input.risk_level) {
787
822
  trace.push({
@@ -877,13 +912,19 @@ function gateBeforeActivation(input) {
877
912
  }
878
913
  function gateAfterActivation(args) {
879
914
  const { input, activation, estimatedRequirements } = args;
915
+ const declaredSurfaces = args.declaredSurfaces === true;
880
916
  const reasons = [];
881
917
  const suggestions = [];
882
918
  // P3 do ciclo MP1 (2026-08-31): o gate conta SUPERFÍCIES (famílias dos concerns
883
919
  // primários de cada sinal), não o total de famílias activadas — concerns de
884
920
  // suporte de um mesmo sinal (mtls→secrets, mensageria→logging) não pedem
885
921
  // decomposição. GC-10 é o caso de referência: 1 integração legítima.
886
- if (activation.decompositionFamilies.length > 3) {
922
+ // 0.20.0-beta.21 («declarativo primeiro»): o gate de decomposição nasceu para travar
923
+ // pedidos VAGOS cuja prosa activava meio catálogo. Quando as famílias vêm de
924
+ // DECLARAÇÕES explícitas, bloquear contradiz o contrato — o chamador não foi vago,
925
+ // foi preciso. O guarda do tamanho da resposta passa a ser (e já era) o tecto de
926
+ // requisitos por detail (0.19.4). Em `discover` a regra mantém-se tal e qual.
927
+ if (!declaredSurfaces && activation.decompositionFamilies.length > 3) {
887
928
  reasons.push(`Pedido activa ${activation.decompositionFamilies.length} superfícies (famílias primárias: ${activation.decompositionFamilies.join(", ")}) — máximo recomendado: 3. Concerns de suporte do mesmo sinal não contam.`);
888
929
  suggestions.push("Reparte por slice family. Cada PR/PR-step deve ficar em 1–3 slices.");
889
930
  }
@@ -896,7 +937,7 @@ function gateAfterActivation(args) {
896
937
  // D1 (G-mp1a): with the requirement-count cap gone, the no-signal guard is the
897
938
  // vagueness catch-all. The informational risk_level trace entry must not defeat
898
939
  // it — only real signals (concerns) count.
899
- if (activation.concerns.length === 0 && input.tokenCount >= 4) {
940
+ if (!declaredSurfaces && activation.concerns.length === 0 && input.tokenCount >= 4) {
900
941
  return {
901
942
  status: "needs_clarification",
902
943
  reasons: [
@@ -2153,7 +2194,22 @@ function prepareCodegenContextCore(raw) {
2153
2194
  if (preGate && preGate.status !== "ready_for_codegen") {
2154
2195
  return blocked(input, raw, preGate.status, preGate.reasons, preGate.suggestions, [], { rejected: [], notes: [] });
2155
2196
  }
2156
- const activation = activate(input);
2197
+ // 0.20.0-beta.21 — «declarativo primeiro»: por defeito o prepare responde ao
2198
+ // DECLARADO. Sem nenhuma declaração não adivinha a partir do `task`: devolve
2199
+ // needs_input com o vocabulário e a receita (o gateway semântico e o
2200
+ // classificador de intenção vivem no bloco lexical, que aqui não corre).
2201
+ const selectionMode = raw.selection_mode === "discover" ? "discover" : "declarative";
2202
+ const declaredTechnologies = normalizeDeclaredTechnologies(Array.isArray(raw.technologies) ? raw.technologies.filter((t) => typeof t === "string") : [], input.stack);
2203
+ const declarativeSelection = selectionMode !== "discover";
2204
+ const hasDeclaredActivator = input.concerns.length > 0 ||
2205
+ input.exposure !== undefined ||
2206
+ input.data_sensitivity !== undefined ||
2207
+ input.changed_files.length > 0 ||
2208
+ declaredTechnologies.length > 0;
2209
+ // P1-A (0.20.0-beta.22): a decisão de needs_input é UMA e vive no motor — indexada
2210
+ // à activação produzida, não à presença de campos. O prepare reage ao veredicto
2211
+ // (abaixo, depois de correr a selecção), em vez de ter a sua própria regra.
2212
+ const activation = activate(input, { declaredOnly: declarativeSelection });
2157
2213
  // (c) The scope gate measures the request's FOCUS, not its full semantic
2158
2214
  // expansion: explicit concerns when given, else the concerns activated by
2159
2215
  // deterministic sources (explicit input + direct lexicon task terms). Semantic
@@ -2173,7 +2229,22 @@ function prepareCodegenContextCore(raw) {
2173
2229
  void focusConcerns; // kept for the debug notes below; the gate no longer counts requirements
2174
2230
  // MP1 selection (G-mp1a O2): the engine composes baseline ∪ context and narrows
2175
2231
  // by the task's declared signals — this is the requirement set served.
2176
- const selection = runSelectionWithActivation(input, activation);
2232
+ const selection = runSelectionWithActivation(input, activation, selectionMode === "discover" ? (raw.technologies ?? []) : declaredTechnologies, selectionMode);
2233
+ if (selection.needs_input) {
2234
+ const ni = selection.needs_input;
2235
+ return blocked(input, raw, "needs_input", [
2236
+ ni.reason,
2237
+ "Contrato v1.18-beta: o servidor responde ao declarado e NÃO interpreta o `task` — que fica registado para auditoria."
2238
+ ], [
2239
+ `Lê o vocabulário fechado: read_sbd_toe_resource(uri="${ni.vocabulary_resource}").`,
2240
+ `Re-chama declarando, por exemplo: ${ni.example.with}.`,
2241
+ ...(ni.candidates_to_confirm.from_task_text.length > 0
2242
+ ? [`SUGESTÃO A CONFIRMAR (não é selecção), derivada do texto: [${ni.candidates_to_confirm.from_task_text.join(", ")}] — confirma e declara.`]
2243
+ : []),
2244
+ ...(ni.inert_declarations?.length ? [`Declarações inertes nesta chamada: ${ni.inert_declarations.join("; ")}.`] : []),
2245
+ `Queres o comportamento inferencial antigo? selection_mode="discover" (exploratório).`
2246
+ ], activation.trace);
2247
+ }
2177
2248
  const estimatedRequirements = selection.selected.length;
2178
2249
  // 0.19.4 («a promessa do minimal», lead opção 2): tecto de requisitos por-id
2179
2250
  // por nível de detail, derivado da medição (~68 tk/req min/std, ~29 ultrathin)
@@ -2232,7 +2303,11 @@ function prepareCodegenContextCore(raw) {
2232
2303
  const postGate = gateAfterActivation({
2233
2304
  input,
2234
2305
  activation,
2235
- estimatedRequirements
2306
+ estimatedRequirements,
2307
+ // Só é "declarado" quando o chamador declarou mesmo: no caminho declarativo a
2308
+ // ausência de declarações já devolveu needs_input antes de chegar aqui, logo
2309
+ // este ponto implica declaração real. Em discover o gate mantém-se inteiro.
2310
+ declaredSurfaces: declarativeSelection && hasDeclaredActivator
2236
2311
  });
2237
2312
  if (postGate && postGate.status !== "ready_for_codegen") {
2238
2313
  return blocked(input, raw, postGate.status, postGate.reasons, postGate.suggestions, activation.trace, { rejected: activation.rejected, notes: activation.notes });