raptor-aios 0.15.0 → 0.16.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 (44) hide show
  1. package/CHANGELOG.md +117 -1
  2. package/README.md +1 -1
  3. package/dist/_core/dist/agents/prompt-builder.js +9 -5
  4. package/dist/_core/dist/audit/logger.js +16 -5
  5. package/dist/_core/dist/audit/schema.js +10 -0
  6. package/dist/_core/dist/audit/validate.js +15 -0
  7. package/dist/_core/dist/constitution/core.js +4 -0
  8. package/dist/_core/dist/constitution/index.js +1 -1
  9. package/dist/_core/dist/design/fetcher.js +12 -6
  10. package/dist/_core/dist/design/scaffold.js +65 -17
  11. package/dist/_core/dist/design/screens.js +5 -1
  12. package/dist/_core/dist/frontmatter/index.js +26 -0
  13. package/dist/_core/dist/gates/design-gates.js +135 -5
  14. package/dist/_core/dist/gates/m7-gates.js +5 -2
  15. package/dist/_core/dist/models/impl-log.js +22 -1
  16. package/dist/_core/dist/verify/fidelity.js +7 -3
  17. package/dist/_core/package.json +1 -1
  18. package/dist/_core/templates/plan.md.hbs +4 -6
  19. package/dist/_core/templates/spec.md.hbs +16 -8
  20. package/dist/commands/approve.js +5 -3
  21. package/dist/commands/clarify.js +2 -1
  22. package/dist/commands/design/fidelity.js +41 -10
  23. package/dist/commands/design/status.js +42 -6
  24. package/dist/commands/design/sync.js +1 -1
  25. package/dist/commands/doctor.js +0 -8
  26. package/dist/commands/implement.js +47 -0
  27. package/dist/commands/new.js +3 -3
  28. package/dist/commands/preset/add.js +8 -12
  29. package/dist/commands/trace.js +2 -0
  30. package/dist/commands/upgrade.js +10 -13
  31. package/dist/commands/verify/fidelity.js +4 -0
  32. package/dist/shared/init-core.js +2 -1
  33. package/dist/shared/scaffold.js +15 -16
  34. package/package.json +1 -1
  35. package/scripts/bash/common.sh +13 -8
  36. package/scripts/bash/create-new-feature.sh +67 -11
  37. package/scripts/prepare-npm.mjs +1 -1
  38. package/templates/commands/analyze.md +1 -1
  39. package/templates/commands/clarify.md +31 -12
  40. package/templates/commands/constitution.md +3 -3
  41. package/templates/commands/plan.md +2 -2
  42. package/templates/commands/specify.md +14 -2
  43. package/templates/plan-template.md +1 -1
  44. package/templates/spec-template.md +2 -2
package/CHANGELOG.md CHANGED
@@ -3,7 +3,123 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
- ## [Unreleased]
6
+ ## [0.16.0] - 2026-06-24
7
+
8
+ Correções P0/P1/P2 do re-teste KAN-20 v2 (DuCorre/FastBoy): fechar o **processo
9
+ de fidelidade** para o erro não nascer, não só ser pego no fim, e endurecer
10
+ rastreabilidade/determinismo nas pontas (resiliência de gate, hash unificado,
11
+ backbone de fidelidade instalado, clarify determinístico, drift acionável). Esta
12
+ versão também **entrega os dois deferidos** — constituição em **fonte única** e
13
+ **edições pós-implement auditadas** — e incorpora os achados de uma **revisão
14
+ integrada adversarial** que a suíte verde escondia (corrupção latente do
15
+ `audit.jsonl` por campo `undefined`; vazamento residual do laço de fidelidade).
16
+
17
+ ### Added
18
+
19
+ - **`gate.design.anatomy`** (M3, _required_) — a `## Anatomy` (contrato de fidelidade)
20
+ precisa **nascer do pixel real**: cada tela com anatomia tem que registrar um
21
+ `figma_screenshot:` (capturado via `get_screenshot`) presente em disco e ≥ 1 KB. Sem
22
+ isso o gate bloqueia o verify — impede o contrato de nascer cego e o `implement`
23
+ construir fielmente a coisa errada (F10-2). Os prompts (`spec.md.hbs`, `/raptor-specify`)
24
+ agora mandam capturar o screenshot ANTES de autorar a anatomia.
25
+ - **Backbone de fidelidade instalado no projeto** — `init`/`upgrade` agora copiam
26
+ `scripts/review/{hex-scan,diff-tokens}.mjs` e `scripts/app/screenshot.sh` para
27
+ `.raptor/scripts/`, e o prompt do scorer referencia `.raptor/scripts/...`. Antes os
28
+ comandos do scorer apontavam para `scripts/...` que nenhum comando instalava — hex-scan
29
+ e o screenshot do app ficavam indisponíveis no caminho documentado (F8-1).
30
+ - **`implement --finalize` audita edições pós-implement** — quando o finalize absorve um
31
+ artefato que mudou após ser registrado (drift), emite o evento `implement.drift_detected`
32
+ (dedup por `(path|hash)`, idempotente), tornando a edição pós-Fase-7 visível no trilho de
33
+ audit em vez de reconciliada em silêncio. A detecção é pura (`reconstructImplLogFromAudit`
34
+ expõe os drifts); o `verify` segue read-only e o gate M7 segue puro (F7-untracked).
35
+
36
+ ### Changed
37
+
38
+ - **`raptor design fidelity` virou laço fechado** — sai **não-zero** enquanto houver
39
+ região < threshold, scorecard com problema ou `design-decision` pendente, e **0** só na
40
+ convergência. Um laço de agente/CI itera até passar, em vez de a convergência depender
41
+ de disciplina manual (F10-3). O `evaluateFeatureFidelity` agora expõe
42
+ `pendingDesignDecisions`, e tanto o loop quanto a lente `raptor verify fidelity` passam a
43
+ reportar **não-ok** numa `design-decision` pendente — alinhados com `gate.design.fidelity`
44
+ (antes a lente dava verde enquanto o gate bloqueava).
45
+ - **`raptor design fidelity` converge ao veredito do gate (vazamento residual do F10-3)** — a
46
+ lente do laço só olhava os scorecards, então o comando podia sair **0** ("converged") enquanto
47
+ `raptor verify` falharia por falta de evidência de screenshot ou cobertura por região da
48
+ anatomia. Agora o comando roda `gate.design.fidelity` e `gate.design.anatomy` como autoridade
49
+ da convergência (só engajam com spec aprovada). Um laço de agente que parava no verde otimista
50
+ passa a iterar até o gate de fato passar.
51
+ - **Constituição em fonte única** — `.raptor/constitution.md` passa a ser a **única** cópia; o
52
+ espelho `.raptor/memory/constitution.md` foi removido. `init` não o escreve mais, `upgrade`
53
+ remove o órfão (com backup antes), e `preset add`/`doctor` só olham o canônico. Os prompts
54
+ (`templates/commands/*`, `plan.md.hbs`), o `scripts/bash/common.sh` (`CONSTITUTION_PATH`) e os
55
+ docs foram repointados; snapshots E2E regenerados. Fim da duplicação que o `upgrade` mantinha
56
+ em sync (F5-1b). _(Scripts PowerShell ficam como follow-up.)_
57
+ - **Skill `/raptor-clarify` alinhada ao caminho determinístico** — a skill 2.1.0 deixou de
58
+ hand-editar `## Clarifications` num formato (`### Session` + `- Q:→A:`) divergente do CLI;
59
+ agora emite `answers.json` e roda `raptor clarify --apply` (o CLI é o dono único da seção,
60
+ formato canônico `- **label** — answer`, UPSERT idempotente). Fim do spec híbrido de dois
61
+ escritores (F3-1/2). Os snapshots E2E passam a cobrir `clarify` (trava o formato).
62
+ - **Drift do impl-log agora é acionável** — `gate.m7.diff_hash_matches` já re-hasha o working
63
+ tree no `verify` e flaga arquivo que mudou/sumiu após ser registrado; a mensagem agora nomeia
64
+ a causa (**impl-log STALE** — edição pós-implement não re-logada) e a remediação
65
+ (`raptor implement --finalize` ou reverter) (F7-stale).
66
+
67
+ ### Fixed
68
+
69
+ - **`.gitignore` não engole mais os assets do app** — o `--design-fetch` deixou de
70
+ ignorar os diretórios `assets/{images,icons,fonts}` inteiros; agora ignora apenas os
71
+ arquivos exatos que o RAPTOR baixou (bloco gerenciado único, idempotente). Assets
72
+ próprios do app (fontes, imagens de onboarding) param de sumir silenciosamente do
73
+ controle de versão (F1-2).
74
+ - **`/raptor-specify` não duplica mais uma feature já seedada** — `create-new-feature.sh`
75
+ ganhou guarda determinística por **slug** (espelhando o `raptor new`): um
76
+ `.raptor/specs/NNN-<slug>` existente é **reusado** (`REUSED: true`), não recriado com um
77
+ número novo que orfanaria a feature seedada (F2-1). `--number` e `--timestamp` optam por
78
+ fora.
79
+ - **`gate.design.ready` resiliente a YAML inválido** — um campo irmão malformado no
80
+ frontmatter de uma tela (ex.: um `screen_role` não-quotado escrito pelo agente) fazia o
81
+ `parseFrontmatter` lançar e o gate descartar o `name`, caindo no slug do arquivo que nunca
82
+ casa (`[screen: X]` reportado como ausente). Novo helper `frontmatterField()` lê o campo
83
+ por line-scan quando o parse YAML falha; usado em `gate.design.ready` e `gate.design.anatomy`
84
+ (F9-1).
85
+ - **Hash da constituição unificado** — dentro do mesmo comando, o prompt-context gravava o
86
+ hash whole-file enquanto `plan`/audit gravavam o hash do bloco RAPTOR-CORE. `buildCanonicalPrompt`
87
+ passa a registrar o **mesmo** core-block hash (helper `constitutionHashFromContent`), então
88
+ prompt e artefato concordam em "qual constituição governou isto" (F5-1).
89
+ - **Serializer do `audit.jsonl` robusto a `undefined`** — o `stableStringify` emitia
90
+ `"chave":undefined` (JSON inválido) para uma propriedade com valor `undefined`, corrompendo a
91
+ linha e a cadeia `prev_hash` a partir dela — e isso **disparava de fato** em `raptor plan`/`init`
92
+ num projeto sem preset. Agora omite chaves `undefined` e mapeia `undefined` em array para `null`
93
+ (espelhando `JSON.stringify`). Achado da revisão integrada adversarial.
94
+ - **`upgrade` não apaga mais o espelho da constituição sem backup** (`backup()` antes do `rmSync`),
95
+ e a regex que remove a chave morta `branch_prefix` foi **ancorada na coluna 0** (não casa uma
96
+ chave homônima aninhada sob outra seção). Achados da revisão integrada.
97
+
98
+ #### Polimento (P2)
99
+
100
+ - **`raptor approve` emite `spec.approved`** (evento distinto, não mais o genérico `spec.updated`) e
101
+ grava `audit_ref` no frontmatter apontando para o id da entrada do audit — link bidirecional
102
+ artefato↔audit (C4), antes sempre null (F4-1/F4-2).
103
+ - **`clarify --apply` faz backup do spec** antes de sobrescrever (a recuperação não depende mais só
104
+ do ledger/hashes) (F3-3).
105
+ - **Warning de scope não empurra mais `--design-scope file`** quando outras refs foram capturadas —
106
+ uma ref sem node-id agora vira aviso suave ("linke o frame específico"), e o escape hatch
107
+ file-wide só aparece quando NADA foi capturado (F1-warn).
108
+ - **Nome de tela duplicado é desambiguado** — dois frames Figma com o mesmo `name` ganham um sufixo
109
+ de node-id no 2º+ , para `[screen: X]` resolver para um arquivo só (F1-dup).
110
+ - **`audit` `file_keys` deduplicado**; **tabela `Post-Design Check` do `plan.md.hbs`** consertada
111
+ (separador/linha partidos quebravam a renderização) (F5-2).
112
+ - **`audit.jsonl` ganha cadeia tamper-evident** — cada evento grava `prev_hash` (sha256 da linha
113
+ anterior; null no genesis), e `raptor verify audit --strict` valida a cadeia e detecta adulteração
114
+ de eventos passados. Campo opcional no schema (back-compat com logs pré-cadeia) (prev_hash).
115
+ - **Branch do `/raptor-specify` (bash) alinhada à convenção do `raptor new`** — o branch agora carrega
116
+ o prefixo `feat/` (`feat/NNN-slug`); o diretório da spec continua o `NNN-slug` puro. `get_next_feature_number`
117
+ e `check_feature_branch` passam a entender o prefixo. `raptor upgrade` remove a chave morta
118
+ `branch_prefix` de projetos legados e o label "legacy cleanup" só aparece quando algo foi de fato
119
+ limpo (F2-2 / F0-2 / F0-3).
120
+ - **`raptor design status --check`** valida o token figma-rest de verdade (chamada `/v1/me`): um token
121
+ clobberado/revogado-mas-presente agora reporta NOT connected, em vez do falso "connected" por
122
+ presença (F0-1).
7
123
 
8
124
  ## [0.15.0] - 2026-06-22
9
125
 
package/README.md CHANGED
@@ -237,7 +237,7 @@ Você os executa **dentro do seu agente de IA**. São prompts versionados e rico
237
237
 
238
238
  ### 🧭 `/raptor-constitution`
239
239
 
240
- Cria ou atualiza os princípios do projeto (`.raptor/memory/constitution.md`). É o "contrato social" que todas as fases seguintes respeitam. Use no início, ou quando quiser ratificar uma mudança de regra (com bump de versão).
240
+ Cria ou atualiza os princípios do projeto (`.raptor/constitution.md`). É o "contrato social" que todas as fases seguintes respeitam. Use no início, ou quando quiser ratificar uma mudança de regra (com bump de versão).
241
241
 
242
242
  ### 📝 `/raptor-specify`
243
243
 
@@ -1,6 +1,7 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { hashString, hashFile } from "../audit/hash.js";
4
+ import { constitutionHashFromContent } from "../constitution/core.js";
4
5
  import { wrapSpec, wrapUntrusted } from "./prompt-markers.js";
5
6
  const PHASE_MAP = {
6
7
  new: {
@@ -80,14 +81,14 @@ const PHASE_MAP = {
80
81
  "2. Extraia do Figma os valores REAIS do nó com `get_design_context` / `get_variable_defs` /",
81
82
  " `get_screenshot`. REGRA DE OURO: o nó vence a percepção — nunca chute px/cor/raio/peso.",
82
83
  "3. Capture o app RODANDO (evidência, não invente): rode",
83
- " `scripts/app/screenshot.sh design/visual-review/_shots/<tela>.<estado>.app.png` e salve o",
84
+ " `.raptor/scripts/app/screenshot.sh design/visual-review/_shots/<tela>.<estado>.app.png` e salve o",
84
85
  " screenshot do Figma como `…<estado>.figma.png`.",
85
86
  "4. Compare Figma×app POR MICRO-REGIÃO e pontue 60–100 em cada dimensão",
86
87
  " (layout, spacing, color, typography, dimensions, icon, states). A nota da região = a",
87
88
  " dimensão MAIS FRACA (o elo). Mínimo de aprovação = 95 (ou o threshold do preset).",
88
- "5. Backbone determinístico (não falsifique): rode `node scripts/review/hex-scan.mjs src --json`",
89
+ "5. Backbone determinístico (não falsifique): rode `node .raptor/scripts/review/hex-scan.mjs src --json`",
89
90
  " e preencha `deterministic.hex_hardcoded` com o resultado REAL; rode",
90
- " `node scripts/review/diff-tokens.mjs <figma-tokens.json> design/tokens.json --json` p/ o drift.",
91
+ " `node .raptor/scripts/review/diff-tokens.mjs <figma-tokens.json> design/tokens.json --json` p/ o drift.",
91
92
  "6. Registre cada divergência `{ dimension, figma, rn, fix, severity, status }`. `status`:",
92
93
  " `resolved` (corrigida), `open` (ainda falha) ou `design-decision` (desvio semântico —",
93
94
  " troca de ícone/cor/copy — que EXIGE aprovação humana antes de passar o gate).",
@@ -284,7 +285,10 @@ export function buildCanonicalPrompt(input) {
284
285
  ? join(raptorDir, "constitution.md")
285
286
  : join(featureDir, inp.filename);
286
287
  if (existsSync(path)) {
287
- inputs.push({ path: inp.filename, hash: hashFile(path), role: inp.role });
288
+ const hash = inp.filename === "constitution.md"
289
+ ? constitutionHashFromContent(readFileSync(path, "utf8"))
290
+ : hashFile(path);
291
+ inputs.push({ path: inp.filename, hash, role: inp.role });
288
292
  }
289
293
  }
290
294
  let body = phase.bodyTemplate;
@@ -57,15 +57,25 @@ export function nextSequence(jsonlPath) {
57
57
  }
58
58
  return lines.length + 1;
59
59
  }
60
+ export function lastEventHash(jsonlPath) {
61
+ if (!existsSync(jsonlPath))
62
+ return null;
63
+ const trimmed = readFileSync(jsonlPath, "utf8").trimEnd();
64
+ if (trimmed.length === 0)
65
+ return null;
66
+ const lines = trimmed.split("\n");
67
+ return hashString(lines[lines.length - 1]);
68
+ }
60
69
  function stableStringify(obj) {
61
70
  if (obj === null || typeof obj !== "object")
62
71
  return JSON.stringify(obj);
63
72
  if (Array.isArray(obj))
64
- return "[" + obj.map(stableStringify).join(",") + "]";
65
- const keys = Object.keys(obj).sort();
66
- const parts = keys.map((k) => JSON.stringify(k) +
67
- ":" +
68
- stableStringify(obj[k]));
73
+ return ("[" +
74
+ obj.map((v) => (v === undefined ? "null" : stableStringify(v))).join(",") +
75
+ "]");
76
+ const o = obj;
77
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
78
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + stableStringify(o[k]));
69
79
  return "{" + parts.join(",") + "}";
70
80
  }
71
81
  export function appendAuditEvent(jsonlPath, event) {
@@ -75,6 +85,7 @@ export function appendAuditEvent(jsonlPath, event) {
75
85
  id: id ?? uuidv7(),
76
86
  sequence: sequence ?? nextSequence(jsonlPath),
77
87
  ts: ts ?? new Date().toISOString(),
88
+ prev_hash: lastEventHash(jsonlPath),
78
89
  ...rest,
79
90
  };
80
91
  assertC5(full);
@@ -27,6 +27,10 @@ export const auditEventSchema = {
27
27
  format: "date-time",
28
28
  description: "ISO 8601 with timezone, millisecond precision recommended.",
29
29
  },
30
+ prev_hash: {
31
+ type: ["string", "null"],
32
+ description: "sha256 of the previous event's serialized line — a tamper-evident chain; null for the genesis event. Optional for back-compat with pre-chain logs.",
33
+ },
30
34
  event: {
31
35
  type: "string",
32
36
  enum: [
@@ -34,6 +38,7 @@ export const auditEventSchema = {
34
38
  "feature.archived",
35
39
  "spec.generated",
36
40
  "spec.updated",
41
+ "spec.approved",
37
42
  "clarify.added",
38
43
  "clarify.scanned",
39
44
  "clarify.applied",
@@ -48,6 +53,7 @@ export const auditEventSchema = {
48
53
  "checklist.generated",
49
54
  "checklist.created",
50
55
  "task.completed",
56
+ "implement.drift_detected",
51
57
  "gate.evaluated",
52
58
  "gate.skipped",
53
59
  "gate.approved",
@@ -152,6 +158,10 @@ export const auditEventSchema = {
152
158
  if: { properties: { event: { const: "code.generated" } } },
153
159
  then: { required: ["outputs", "task"] },
154
160
  },
161
+ {
162
+ if: { properties: { event: { const: "implement.drift_detected" } } },
163
+ then: { required: ["outputs", "detector"] },
164
+ },
155
165
  {
156
166
  if: {
157
167
  properties: {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { Ajv2020, } from "ajv/dist/2020.js";
3
3
  import { auditEventSchema } from "./schema.js";
4
+ import { hashString } from "./hash.js";
4
5
  let cachedValidator = null;
5
6
  function getValidator() {
6
7
  if (cachedValidator)
@@ -75,6 +76,7 @@ export function validateAuditFile(jsonlPath, opts = {}) {
75
76
  let validLines = 0;
76
77
  let prevSequence = 0;
77
78
  let prevTs = "";
79
+ let prevRaw = null;
78
80
  for (let i = 0; i < lines.length; i++) {
79
81
  const lineNum = i + 1;
80
82
  const raw = lines[i];
@@ -87,6 +89,8 @@ export function validateAuditFile(jsonlPath, opts = {}) {
87
89
  });
88
90
  continue;
89
91
  }
92
+ const prevLine = prevRaw;
93
+ prevRaw = raw;
90
94
  let parsed;
91
95
  try {
92
96
  parsed = JSON.parse(raw);
@@ -125,6 +129,17 @@ export function validateAuditFile(jsonlPath, opts = {}) {
125
129
  keyword: "monotonicTs",
126
130
  });
127
131
  }
132
+ if (obj.prev_hash !== undefined) {
133
+ const expected = prevLine === null ? null : hashString(prevLine);
134
+ if (obj.prev_hash !== expected) {
135
+ errors.push({
136
+ line: lineNum,
137
+ path: "/prev_hash",
138
+ message: `prev_hash chain broken — expected ${expected ?? "null (genesis)"}, got ${obj.prev_hash ?? "null"}`,
139
+ keyword: "chain",
140
+ });
141
+ }
142
+ }
128
143
  prevSequence = obj.sequence;
129
144
  prevTs = obj.ts;
130
145
  }
@@ -89,6 +89,10 @@ export function extractCoreBlock(source) {
89
89
  endIndex: endMarkerIdx + CORE_END_MARKER.length,
90
90
  };
91
91
  }
92
+ export function constitutionHashFromContent(content) {
93
+ const extracted = extractCoreBlock(content);
94
+ return extracted ? extracted.declaredHash : hashString(content);
95
+ }
92
96
  export function rewriteCoreBlock(source) {
93
97
  const canonical = coreBlock();
94
98
  const extracted = extractCoreBlock(source);
@@ -1 +1 @@
1
- export { CORE_VERSION, CORE_ARTICLES_TEXT, CORE_END_MARKER, CORE_START_MARKER_PREFIX, CORE_START_MARKER_RE, coreBlock, coreHash, extractCoreBlock, rewriteCoreBlock, } from "./core.js";
1
+ export { CORE_VERSION, CORE_ARTICLES_TEXT, CORE_END_MARKER, CORE_START_MARKER_PREFIX, CORE_START_MARKER_RE, coreBlock, coreHash, constitutionHashFromContent, extractCoreBlock, rewriteCoreBlock, } from "./core.js";
@@ -6,15 +6,19 @@ function warn(opts, message) {
6
6
  else
7
7
  process.stderr.write(`Warning: ${message}\n`);
8
8
  }
9
- function scopeSkipWarning(url) {
9
+ function scopeSkipWarning(url, someCaptured) {
10
+ if (someCaptured) {
11
+ return (`ref sem node-id (link sem ?node-id=); só ESTE link não foi capturado — ` +
12
+ `linke o frame específico no card para incluí-lo: ${url}`);
13
+ }
10
14
  return (`ref sem node-id (link sem ?node-id=); telas/assets não capturados — ` +
11
- `rode com --design-scope file para o arquivo inteiro, ou linke o frame no card: ${url}`);
15
+ `linke o frame no card, ou rode com --design-scope file para o arquivo inteiro: ${url}`);
12
16
  }
13
- function warnOnce(opts, seen, url) {
17
+ function warnOnce(opts, seen, url, someCaptured) {
14
18
  if (seen.has(url))
15
19
  return;
16
20
  seen.add(url);
17
- warn(opts, scopeSkipWarning(url));
21
+ warn(opts, scopeSkipWarning(url, someCaptured));
18
22
  }
19
23
  export async function fetchDesignData(refs, client, opts = {}) {
20
24
  const varMap = {};
@@ -59,9 +63,10 @@ async function captureScreens(refs, client, opts, scopeWarned) {
59
63
  const scope = opts.scope ?? "node";
60
64
  const out = [];
61
65
  const seen = new Set();
66
+ const someCaptured = refs.some((r) => Boolean(r.nodeId));
62
67
  for (const ref of refs) {
63
68
  if (scope === "node" && !ref.nodeId) {
64
- warnOnce(opts, scopeWarned, ref.url);
69
+ warnOnce(opts, scopeWarned, ref.url, someCaptured);
65
70
  continue;
66
71
  }
67
72
  try {
@@ -90,10 +95,11 @@ async function captureAssets(refs, client, typography, opts, scopeWarned) {
90
95
  const pending = [];
91
96
  const seenPaths = new Set();
92
97
  const scope = opts.scope ?? "node";
98
+ const someCaptured = refs.some((r) => Boolean(r.nodeId));
93
99
  if (client.captureAssets) {
94
100
  for (const ref of refs) {
95
101
  if (scope === "node" && !ref.nodeId) {
96
- warnOnce(opts, scopeWarned, ref.url);
102
+ warnOnce(opts, scopeWarned, ref.url, someCaptured);
97
103
  continue;
98
104
  }
99
105
  try {
@@ -1,9 +1,9 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { isTokensEmpty, parseDesignTokens } from "./tokens.js";
4
4
  import { parseAssetManifest } from "./manifest.js";
5
5
  import { renderScreenMarkdown, screenSlug } from "./screens.js";
6
- import { slugify } from "./slug.js";
6
+ import { normalizePath, slugify } from "./slug.js";
7
7
  import { ASSET_MANIFEST_SCHEMA, } from "./types.js";
8
8
  export const ASSET_DIRS = [
9
9
  "assets/images",
@@ -81,28 +81,64 @@ export function writeDesignScaffold(featureDir, root, refs) {
81
81
  created.push(`${dir}/.gitkeep`);
82
82
  }
83
83
  }
84
- if (ensureAssetGitignore(root)) {
85
- created.push(".gitignore");
86
- }
87
84
  return created;
88
85
  }
89
86
  const GITIGNORE_BEGIN = "# RAPTOR design assets";
90
87
  const GITIGNORE_END = "# /RAPTOR design assets";
91
- function ensureAssetGitignore(root) {
88
+ function ensureAssetGitignore(root, relPaths) {
92
89
  try {
90
+ const wanted = [...new Set(relPaths.map((p) => normalizePath(p)).filter(Boolean))];
91
+ if (wanted.length === 0)
92
+ return false;
93
93
  const gitignorePath = join(root, ".gitignore");
94
- const existing = existsSync(gitignorePath)
95
- ? readFileSync(gitignorePath, "utf8")
96
- : "";
97
- if (existing.includes(GITIGNORE_BEGIN))
94
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
95
+ const lines = existing.split(/\r?\n/);
96
+ let begin = -1;
97
+ let end = -1;
98
+ for (let i = 0; i < lines.length; i++) {
99
+ if (begin === -1 && lines[i].trim() === GITIGNORE_BEGIN)
100
+ begin = i;
101
+ else if (begin !== -1 && lines[i].trim() === GITIGNORE_END) {
102
+ end = i;
103
+ break;
104
+ }
105
+ }
106
+ const wellFormed = begin !== -1 && end !== -1;
107
+ if (begin !== -1 && end === -1)
108
+ end = lines.length;
109
+ const hasBlock = begin !== -1;
110
+ const blockEntries = hasBlock
111
+ ? lines.slice(begin + 1, end).map((l) => l.trim()).filter(Boolean)
112
+ : [];
113
+ const outside = new Set((hasBlock ? [...lines.slice(0, begin), ...lines.slice(end + 1)] : lines).map((l) => l.trim()));
114
+ const managed = new Set(blockEntries);
115
+ for (const p of wanted) {
116
+ if (!outside.has(p) && !outside.has(`/${p}`))
117
+ managed.add(p);
118
+ }
119
+ const newEntries = [...managed].sort();
120
+ if (newEntries.length === 0)
98
121
  return false;
99
- const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
100
- const missing = ASSET_DIRS.filter((d) => !lines.has(d) && !lines.has(`/${d}`));
101
- if (missing.length === 0)
122
+ const oldSorted = [...new Set(blockEntries)].sort();
123
+ if (wellFormed && newEntries.length === oldSorted.length && newEntries.every((e, i) => e === oldSorted[i])) {
102
124
  return false;
103
- const block = (existing.length && !existing.endsWith("\n") ? "\n" : "") +
104
- `${GITIGNORE_BEGIN}\n${missing.join("\n")}\n${GITIGNORE_END}\n`;
105
- appendFileSync(gitignorePath, block);
125
+ }
126
+ let outLines;
127
+ if (hasBlock) {
128
+ outLines = [...lines.slice(0, begin), GITIGNORE_BEGIN, ...newEntries, GITIGNORE_END, ...lines.slice(end + 1)];
129
+ }
130
+ else {
131
+ outLines = existing.length ? [...lines] : [];
132
+ while (outLines.length && outLines[outLines.length - 1].trim() === "")
133
+ outLines.pop();
134
+ if (outLines.length)
135
+ outLines.push("");
136
+ outLines.push(GITIGNORE_BEGIN, ...newEntries, GITIGNORE_END);
137
+ }
138
+ let next = outLines.join("\n");
139
+ if (!next.endsWith("\n"))
140
+ next += "\n";
141
+ writeFileSync(gitignorePath, next);
106
142
  return true;
107
143
  }
108
144
  catch {
@@ -145,6 +181,7 @@ export function seedDesignArtifacts(featureDir, root, capture, refs) {
145
181
  }
146
182
  const screensDir = join(designDir, "screens");
147
183
  const used = new Set();
184
+ const usedNames = new Set();
148
185
  for (const screen of capture.screens) {
149
186
  const base = screenSlug(screen);
150
187
  let slug = screen.nodeId ? `${base}-${slugify(screen.nodeId)}` : base;
@@ -154,9 +191,20 @@ export function seedDesignArtifacts(featureDir, root, capture, refs) {
154
191
  n++;
155
192
  }
156
193
  used.add(slug);
157
- if (writeIfMissing(join(screensDir, `${slug}.md`), renderScreenMarkdown(screen))) {
194
+ const nameKey = slugify(screen.name);
195
+ const screenToRender = nameKey && usedNames.has(nameKey) && screen.nodeId
196
+ ? { ...screen, name: `${screen.name} (${screen.nodeId})` }
197
+ : screen;
198
+ if (nameKey)
199
+ usedNames.add(nameKey);
200
+ if (writeIfMissing(join(screensDir, `${slug}.md`), renderScreenMarkdown(screenToRender))) {
158
201
  result.screensWritten.push(slug);
159
202
  }
160
203
  }
204
+ const downloaded = capture.assets
205
+ .filter((a) => a.status === "downloaded")
206
+ .map((a) => a.path);
207
+ if (downloaded.length > 0)
208
+ ensureAssetGitignore(root, downloaded);
161
209
  return result;
162
210
  }
@@ -213,7 +213,11 @@ export function renderScreenMarkdown(screen) {
213
213
  data["summary"] = screen.summary;
214
214
  const display = bodyName(screen.name);
215
215
  const body = `\n<!-- Screen captured from Figma. Flesh out: states, navigation, components, data, edge cases.\n` +
216
- ` Reference it from the spec/tasks as [screen: ${display}]. -->\n\n` +
216
+ ` Reference it from the spec/tasks as [screen: ${display}].\n` +
217
+ ` Before writing the \`## Anatomy\` fidelity contract, CAPTURE this frame with the Figma MCP\n` +
218
+ ` get_screenshot, save the PNG under design/screens/, and record its path in this file's\n` +
219
+ ` frontmatter under \`figma_screenshot:\`. Derive every region/element from that real pixel —\n` +
220
+ ` gate.design.anatomy blocks an anatomy authored without a captured reference. -->\n\n` +
217
221
  `## ${display}\n\n` +
218
222
  `_Placeholder — detail this screen during \`/raptor-specify\`._\n`;
219
223
  return stringifyFrontmatter(data, body);
@@ -11,6 +11,32 @@ export function parseFrontmatter(source) {
11
11
  const data = (yamlBlock.trim().length === 0 ? {} : (parseYaml(yamlBlock) ?? {}));
12
12
  return { data, body, hasFrontmatter: true, raw: source };
13
13
  }
14
+ export function frontmatterField(source, field) {
15
+ const m = source.match(FRONTMATTER_RE);
16
+ if (!m)
17
+ return undefined;
18
+ const block = m[1] ?? '';
19
+ try {
20
+ const data = parseYaml(block);
21
+ const v = data == null ? undefined : data[field];
22
+ if (typeof v === 'string')
23
+ return v;
24
+ if (typeof v === 'number' || typeof v === 'boolean')
25
+ return String(v);
26
+ }
27
+ catch {
28
+ }
29
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ const lm = block.match(new RegExp(`^${escaped}[ \\t]*:[ \\t]*(.*\\S)[ \\t]*$`, 'm'));
31
+ if (!lm)
32
+ return undefined;
33
+ let val = lm[1].trim();
34
+ if ((val.startsWith('"') && val.endsWith('"')) ||
35
+ (val.startsWith("'") && val.endsWith("'"))) {
36
+ val = val.slice(1, -1);
37
+ }
38
+ return val.trim() || undefined;
39
+ }
14
40
  export function stringifyFrontmatter(data, body) {
15
41
  const yamlBlock = stringifyYaml(data).trimEnd();
16
42
  const trimmedBody = body.startsWith('\n') ? body : '\n' + body;