@aksp/opencrew 1.6.3 → 1.7.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 (40) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +50 -5
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md +20 -6
  5. package/templates/_opencrew/.opencrew-version +1 -1
  6. package/templates/_opencrew/core/escritorio/animacao.js +64 -0
  7. package/templates/_opencrew/core/escritorio/app.js +137 -0
  8. package/templates/_opencrew/core/escritorio/cena.js +132 -0
  9. package/templates/_opencrew/core/escritorio/demo.js +79 -0
  10. package/templates/_opencrew/core/escritorio/escala.js +27 -0
  11. package/templates/_opencrew/core/escritorio/index.html +166 -0
  12. package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
  13. package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
  14. package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
  15. package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
  16. package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
  17. package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
  18. package/templates/_opencrew/core/escritorio/modelo.js +29 -0
  19. package/templates/_opencrew/core/escritorio/painel.js +120 -0
  20. package/templates/_opencrew/core/escritorio/quadro.js +106 -0
  21. package/templates/_opencrew/core/escritorio/rota.js +62 -0
  22. package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
  23. package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
  24. package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
  25. package/templates/_opencrew/core/escritorio/sprites.js +187 -0
  26. package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
  27. package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
  28. package/templates/_opencrew/core/runner.pipeline.md +45 -128
  29. package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
  30. package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
  31. package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
  32. package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
  33. package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
  34. package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
  35. package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
  36. package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
  37. package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
  38. package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
  39. package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
  40. package/templates/_opencrew/core/scripts/estado.mjs +96 -0
@@ -0,0 +1,166 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>Escritório</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --fundo: #12131c;
12
+ --painel: #1c1e2c;
13
+ --borda: #2e3350;
14
+ --poco: #0b0c12;
15
+ --tinta: #1a1c2c;
16
+ --papel: #f4f4ee;
17
+ --texto: #ecebe4;
18
+ --apagado: #9aa1b8;
19
+ --ambar: #f4a73b;
20
+ --verde: #5fd47a;
21
+ --vermelho: #e0564c;
22
+ --ceu: #7ec4e0;
23
+ --mono: ui-monospace, "Cascadia Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
24
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ [hidden] { display: none !important; }
28
+ html, body { height: 100%; }
29
+ body { display: flex; flex-direction: column; height: 100vh; height: 100dvh; margin: 0; background: var(--fundo); color: var(--texto); font: 15px/1.45 var(--sans); }
30
+ p, h1, h2, ul { margin: 0; }
31
+
32
+ /* Cabeçalho: a marca, a crew em exibição e o seletor (só com mais de uma crew). */
33
+ .topo { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 12px 16px 10px; background: var(--painel); border-bottom: 4px solid var(--borda); }
34
+ .marca { font: 700 14px/1.2 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--ambar); }
35
+ .marca::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 10px; background: var(--ambar); box-shadow: 4px 4px 0 var(--poco); }
36
+ .crew { font: 700 18px/1.2 var(--mono); overflow-wrap: anywhere; }
37
+ .seletor { margin-left: auto; font: 12px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--apagado); }
38
+ .seletor select { max-width: 60vw; margin-left: 6px; padding: 4px 8px; font: 700 13px var(--mono); color: var(--texto); background: var(--poco); border: 2px solid var(--borda); border-radius: 0; }
39
+ .seletor select:focus-visible { outline: 2px solid var(--ambar); outline-offset: 2px; }
40
+
41
+ /* A frase de abertura fica na tela até o primeiro desenho; sem JavaScript, é o que se lê. */
42
+ .abertura { max-width: 64ch; margin: 24px 16px; padding: 14px 16px; font: 15px/1.5 var(--mono); background: var(--painel); border: 2px solid var(--borda); border-left: 6px solid var(--ambar); }
43
+
44
+ .pagina { flex: 1 1 auto; display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); gap: 16px; min-height: 0; padding: 14px 16px 16px; }
45
+ .palco { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
46
+
47
+ /* O passo atual, com a contagem e a barra de progresso. */
48
+ .andamento { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-height: 28px; }
49
+ .situacao { padding: 2px 8px; font: 700 12px/1.5 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--tinta); background: var(--ceu); box-shadow: 3px 3px 0 var(--poco); }
50
+ [data-execucao="checkpoint"] .situacao { background: var(--ambar); }
51
+ [data-execucao="completed"] .situacao { background: var(--verde); }
52
+ [data-execucao="failed"] .situacao { background: var(--vermelho); }
53
+ .passo { font: 700 15px/1.3 var(--mono); overflow-wrap: anywhere; }
54
+ .barra { position: relative; flex: 1 1 180px; display: flex; height: 14px; background: var(--poco); border: 2px solid var(--borda); }
55
+ .barra span { flex: 0 0 0px; }
56
+ #barra-feito { background: var(--verde); }
57
+ #barra-atual { background: var(--ambar); animation: pulsar 1s steps(1, end) infinite; }
58
+ [data-execucao="failed"] #barra-atual { background: var(--vermelho); animation: none; }
59
+ .barra::after { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, transparent 0, transparent calc(100% / var(--total, 1) - 2px), var(--poco) calc(100% / var(--total, 1) - 2px), var(--poco) calc(100% / var(--total, 1))); }
60
+ @keyframes pulsar { 0% { opacity: 1; } 50% { opacity: .45; } }
61
+
62
+ .avisos { display: flex; flex-direction: column; gap: 4px; min-height: 26px; }
63
+ .aviso, .frase { padding: 3px 10px; font: 13px/1.5 var(--mono); border-left: 6px solid; }
64
+ .aviso { color: #ffd9d4; background: #2a1719; border-color: var(--vermelho); }
65
+ .frase { color: #ffe2b0; background: #2a2113; border-color: var(--ambar); }
66
+
67
+ /* O desenho: o canvas em escala inteira e, por cima, a camada de nomes e balões. A área é medida
68
+ pela página para escolher a escala; a moldura em volta dá espaço à borda e rola se nem a
69
+ escala 1 couber. */
70
+ .moldura { flex: 1 1 auto; display: flex; min-height: 200px; padding: 10px; overflow: auto; }
71
+ .area { flex: 1 1 0; display: flex; min-width: 0; min-height: 0; }
72
+ .sala { position: relative; flex: none; width: 320px; height: 180px; margin: auto; box-shadow: 0 0 0 4px var(--borda), 6px 6px 0 4px #07080d; --u: 1; --fio: max(1px, calc(var(--u) * .5px)); }
73
+ .sala canvas { display: block; width: 100%; height: 100%; image-rendering: crisp-edges; image-rendering: pixelated; }
74
+ .rotulos { position: absolute; inset: 0; overflow: hidden; pointer-events: none; font: 700 clamp(9px, calc(var(--u) * 4.5px), 15px)/1.15 var(--mono); }
75
+ .rotulos div { position: absolute; display: flex; justify-content: center; transition: opacity .15s linear; }
76
+ .rotulos span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
+ .nome span { padding: 0 calc(var(--u) * 1.5px); color: var(--papel); background: rgba(26, 28, 44, .8); }
78
+ .balao { padding-bottom: calc(var(--u) * 2px); }
79
+ .balao span { padding: 0 calc(var(--u) * 2px); color: var(--tinta); background: var(--papel); border: var(--fio) solid var(--tinta); }
80
+ .balao::after { content: ""; position: absolute; bottom: 0; left: calc(50% - var(--u) * 1px - var(--fio)); width: calc(var(--u) * 2px + var(--fio) * 2); height: calc(var(--u) * 2px + var(--fio)); background: var(--papel); border: solid var(--tinta); border-width: 0 var(--fio) var(--fio); }
81
+ .balao.espera span, .balao.espera::after { background: var(--ambar); }
82
+ .faixa { position: absolute; top: 0; right: 0; left: 0; height: calc(var(--u) * 13px); padding: 0 calc(var(--u) * 4px); overflow: hidden; font: 700 clamp(10px, calc(var(--u) * 5.5px), 20px)/calc(var(--u) * 11px) var(--mono); text-align: center; text-overflow: ellipsis; white-space: nowrap; color: var(--tinta); background: var(--ambar); border-bottom: calc(var(--u) * 2px) solid var(--tinta); }
83
+ .etiqueta { position: absolute; right: calc(var(--u) * 3px); bottom: calc(var(--u) * 3px); padding: 0 calc(var(--u) * 2px); font: 700 clamp(10px, calc(var(--u) * 5px), 16px)/1.4 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--ambar); background: rgba(26, 28, 44, .85); border: var(--fio) solid var(--ambar); }
84
+
85
+ .rodape { display: flex; flex-direction: column; gap: 2px; min-height: 42px; }
86
+ .nota { font: 13px/1.5 var(--mono); color: var(--apagado); overflow-wrap: anywhere; }
87
+ .nota.falha { color: #ffd9d4; }
88
+
89
+ /* A lista: todo agente, com o status por extenso e o que fez. */
90
+ .lado { display: flex; flex-direction: column; min-height: 0; background: var(--painel); border: 4px solid var(--borda); box-shadow: 6px 6px 0 #07080d; }
91
+ .lado h2 { padding: 8px 12px; font: 700 12px/1.4 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--apagado); border-bottom: 2px solid var(--borda); }
92
+ .lista { flex: 1 1 auto; padding: 0; overflow: auto; list-style: none; }
93
+ .agente { display: grid; grid-template-columns: 10px 1.6em minmax(0, 1fr); column-gap: 10px; align-items: start; padding: 9px 12px; border-bottom: 2px solid var(--borda); }
94
+ .agente .cor { width: 10px; height: 10px; margin-top: 5px; box-shadow: 0 0 0 2px var(--tinta); }
95
+ .agente .icone { font-size: 17px; line-height: 1.2; text-align: center; }
96
+ .agente .nome { display: block; font: 700 14px/1.3 var(--mono); overflow-wrap: anywhere; }
97
+ .agente .status { display: block; font: 12px/1.5 var(--mono); color: var(--apagado); }
98
+ .agente .sinal { display: inline-block; width: 1.4em; }
99
+ .agente .fez { display: block; font-size: 13px; line-height: 1.4; overflow-wrap: anywhere; opacity: .85; }
100
+ .agente[data-acao="working"], .agente[data-acao="checkpoint"] { background: #222538; }
101
+ .agente[data-acao="working"] .status { color: var(--verde); }
102
+ .agente[data-acao="checkpoint"] .status { color: var(--ambar); }
103
+ .agente[data-acao="done"] .status { color: var(--ceu); }
104
+ .agente[data-acao="failed"] .status { color: var(--vermelho); }
105
+ .agente[data-acao="sem-sinal"] .status { color: var(--texto); }
106
+ .agente[data-acao="skipped"] { opacity: .55; }
107
+
108
+ /* Tela estreita: a lista desce para baixo do desenho e a página rola. */
109
+ @media (max-width: 860px) {
110
+ body { height: auto; min-height: 100vh; min-height: 100dvh; }
111
+ .pagina { display: flex; flex-direction: column; padding: 12px; }
112
+ .moldura { flex: none; min-height: 0; padding: 6px; }
113
+ .area { aspect-ratio: 16 / 9; }
114
+ .lista { overflow: visible; }
115
+ }
116
+ @media (max-width: 359px) {
117
+ .pagina, .moldura { padding-right: 0; padding-left: 0; }
118
+ }
119
+ @media (prefers-reduced-motion: reduce) {
120
+ #barra-atual { animation: none; }
121
+ .rotulos div { transition: none; }
122
+ }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <header class="topo">
127
+ <h1 class="marca">Escritório</h1>
128
+ <p class="crew" id="crew" hidden></p>
129
+ <label class="seletor" id="seletor-caixa" hidden>Crew em exibição <select id="seletor"></select></label>
130
+ </header>
131
+ <p class="abertura" id="abertura">Abrindo o escritório… Se esta mensagem não sumir, abra pelo endereço que o comando /opencrew dashboard mostrou, e não pelo arquivo.</p>
132
+ <main class="pagina" id="pagina" hidden>
133
+ <section class="palco" aria-label="Sala do escritório">
134
+ <div class="andamento">
135
+ <span class="situacao" id="situacao" hidden></span>
136
+ <p class="passo" id="passo" role="status" hidden></p>
137
+ <div class="barra" id="barra" aria-hidden="true" hidden><span id="barra-feito"></span><span id="barra-atual"></span><span id="barra-resto"></span></div>
138
+ </div>
139
+ <div class="avisos">
140
+ <p class="aviso" id="aviso" role="alert" hidden></p>
141
+ <p class="frase" id="frase" hidden></p>
142
+ </div>
143
+ <div class="moldura">
144
+ <div class="area" id="moldura">
145
+ <div class="sala" id="sala">
146
+ <canvas id="tela" width="320" height="180" role="img" aria-label="Desenho do escritório em pixel-art. A lista Equipe diz o que cada agente está fazendo."></canvas>
147
+ <p class="faixa" id="faixa" role="status" hidden></p>
148
+ <div class="rotulos" id="rotulos" aria-hidden="true"></div>
149
+ <span class="etiqueta" id="etiqueta" hidden>Demonstração</span>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ <div class="rodape">
154
+ <p class="nota" id="parada" hidden></p>
155
+ <p class="nota falha" id="motivo" hidden></p>
156
+ <p class="nota" id="passagem" hidden></p>
157
+ </div>
158
+ </section>
159
+ <aside class="lado" aria-label="Equipe">
160
+ <h2>Equipe</h2>
161
+ <ul class="lista" id="lista"></ul>
162
+ </aside>
163
+ </main>
164
+ <script type="module" src="app.js"></script>
165
+ </body>
166
+ </html>
@@ -0,0 +1,93 @@
1
+ // O que cada agente mostra: a ação do boneco e o monitor (tabela da regra 18), o balão, a
2
+ // aparência e a linha dele na lista ao lado do desenho.
3
+ // Puro: sem DOM, sem relógio, sem rede.
4
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 16, 18, 21 e 24 (repositório do OpenCrew).
5
+ import { mesaDe } from './modelo-mesas.js';
6
+ import { STATUS_TEXTO, TEXTOS } from './modelo-textos.js';
7
+
8
+ /** Cor da camisa de cada mesa, na ordem das mesas: 12 cores, nenhuma repetida. */
9
+ export const CAMISAS = Object.freeze([
10
+ '#d95763', '#5b6ee1', '#fbf236', '#37946e',
11
+ '#df7126', '#76428a', '#5fcde4', '#d77bba',
12
+ '#99e550', '#3f3f74', '#cbdbfc', '#8a6f30',
13
+ ]);
14
+ export const CABELOS = Object.freeze(['#222034', '#45283c', '#b0432b', '#f2c94c', '#9aa0a8', '#e9e4d8']);
15
+ export const PELES = Object.freeze(['#f2d3b1', '#e0ac69', '#c68642', '#8d5524', '#6b4226']);
16
+
17
+ /** Número fixo para cada texto (FNV-1a, 32 bits): o mesmo `id` dá sempre o mesmo. */
18
+ function semente(texto) {
19
+ let h = 2166136261;
20
+ for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
21
+ return h >>> 0;
22
+ }
23
+
24
+ /**
25
+ * Cores do boneco, em `#rrggbb`: a camisa vem da posição da mesa; cabelo e pele vêm do `id`,
26
+ * e por isso não mudam quando o agente troca de mesa.
27
+ * @param {string} id
28
+ * @param {number} indiceDaMesa posição no elenco; fora das 12 mesas, `camisa` é `null`
29
+ */
30
+ export function aparencia(id, indiceDaMesa) {
31
+ const h = semente(String(id));
32
+ return {
33
+ camisa: CAMISAS[indiceDaMesa] ?? null,
34
+ cabelo: CABELOS[h % CABELOS.length],
35
+ pele: PELES[(h >>> 8) % PELES.length],
36
+ };
37
+ }
38
+
39
+ // Monitor de cada ação (regra 18). `animado`: linhas correndo (aceso) ou piscando 1 vez por
40
+ // segundo (âmbar); o vermelho é fixo. `sem-sinal` é o desenho de `idle` com um "?" em cima.
41
+ const MONITOR = Object.freeze({
42
+ idle: { cor: 'apagado' },
43
+ working: { cor: 'aceso', animado: true },
44
+ checkpoint: { cor: 'ambar', animado: true },
45
+ done: { cor: 'apagado' },
46
+ skipped: { cor: 'apagado' },
47
+ failed: { cor: 'vermelho' },
48
+ 'sem-sinal': { cor: 'apagado' },
49
+ });
50
+
51
+ /** A ação é o status, menos para quem trabalha numa execução em checkpoint ou sem sinal. */
52
+ function acaoDe(status, contexto) {
53
+ if (status !== 'working') return status;
54
+ if (contexto.execucao === 'checkpoint') return 'idle';
55
+ return contexto.semSinal ? 'sem-sinal' : 'working';
56
+ }
57
+
58
+ function balaoDe(acao, contexto) {
59
+ if (acao === 'working') return contexto.balaoDoPasso;
60
+ return acao === 'checkpoint' ? TEXTOS.aguardando : null;
61
+ }
62
+
63
+ /**
64
+ * Um agente como a página o mostra.
65
+ * @param {object} agente item de `agents` do estado normalizado
66
+ * @param {number} indice posição no elenco
67
+ * @param {object} contexto `{ execucao, balaoDoPasso, semSinal, reduzirMovimento }`, em que
68
+ * `semSinal` é o texto "Sem sinal há …" ou `null`
69
+ * @returns {object} `{ id, indice, nome, icone, status, statusTexto, label, acao, monitor,
70
+ * monitorAnimado, balao, mesa, aparencia }` · `status`: o do arquivo · `acao`: o que o boneco
71
+ * faz (`idle`, `working`, `checkpoint`, `done`, `skipped`, `failed` ou `sem-sinal`) ·
72
+ * `monitor`: `apagado`, `aceso`, `ambar` ou `vermelho` · `balao`: texto ou `null` · `mesa`:
73
+ * a de `MESAS`, ou `null` do 13º em diante
74
+ */
75
+ export function verAgente(agente, indice, contexto) {
76
+ const acao = acaoDe(agente.status, contexto);
77
+ const { cor, animado = false } = MONITOR[acao];
78
+ return {
79
+ id: agente.id,
80
+ indice,
81
+ nome: agente.name,
82
+ icone: agente.icon,
83
+ status: agente.status,
84
+ statusTexto: acao === 'sem-sinal' ? contexto.semSinal : STATUS_TEXTO[agente.status],
85
+ label: agente.label,
86
+ acao,
87
+ monitor: cor,
88
+ monitorAnimado: animado && !contexto.reduzirMovimento,
89
+ balao: balaoDe(acao, contexto),
90
+ mesa: mesaDe(indice),
91
+ aparencia: aparencia(agente.id, indice),
92
+ };
93
+ }
@@ -0,0 +1,71 @@
1
+ // Leitura do estado da execução (`crews/<crew>/state.json`) como ele chega do servidor: aceita o
2
+ // arquivo da 1.6.x e devolve sempre o mesmo formato, sem alterar o objeto recebido.
3
+ // Puro: sem DOM, sem relógio, sem rede.
4
+ // Spec: fase-e1-escritorio-ao-vivo.md, §4 e regra 18 (repositório do OpenCrew).
5
+
6
+ /** Status de agente que a página conhece, na ordem da tabela da regra 18. */
7
+ export const STATUS_DE_AGENTE = Object.freeze(['idle', 'working', 'checkpoint', 'done', 'skipped', 'failed']);
8
+ export const STATUS_DE_EXECUCAO = Object.freeze(['running', 'checkpoint', 'completed', 'failed']);
9
+
10
+ const ehObjeto = (valor) => valor !== null && typeof valor === 'object' && !Array.isArray(valor);
11
+ const inteiro = (valor, minimo) => (Number.isInteger(valor) && valor >= minimo ? valor : null);
12
+
13
+ function texto(valor) {
14
+ if (typeof valor === 'string') return valor;
15
+ return typeof valor === 'number' ? String(valor) : '';
16
+ }
17
+
18
+ /** `delivering` (arquivo antigo) vale como `done`; status desconhecido vale como `idle`. */
19
+ function statusDoAgente(valor) {
20
+ if (valor === 'delivering') return 'done';
21
+ return STATUS_DE_AGENTE.includes(valor) ? valor : 'idle';
22
+ }
23
+
24
+ function lerAgente(bruto, indice) {
25
+ const id = texto(bruto.id) || `agente-${indice + 1}`;
26
+ return {
27
+ id,
28
+ name: texto(bruto.name) || id,
29
+ icon: texto(bruto.icon),
30
+ status: statusDoAgente(bruto.status),
31
+ label: texto(bruto.label),
32
+ };
33
+ }
34
+
35
+ function lerPasso(bruto) {
36
+ const passo = ehObjeto(bruto) ? bruto : {};
37
+ return { current: inteiro(passo.current, 0) ?? 0, total: inteiro(passo.total, 1), label: texto(passo.label) };
38
+ }
39
+
40
+ function lerPassagem(bruto) {
41
+ if (!ehObjeto(bruto) || !texto(bruto.from) || !texto(bruto.to)) return null;
42
+ return { from: texto(bruto.from), to: texto(bruto.to), message: texto(bruto.message), completedAt: texto(bruto.completedAt) };
43
+ }
44
+
45
+ function lerData(valor) {
46
+ const ms = typeof valor === 'string' ? Date.parse(valor) : Number.NaN;
47
+ return Number.isFinite(ms) ? ms : null;
48
+ }
49
+
50
+ /**
51
+ * O estado bruto, conferido campo a campo. Nunca lança erro.
52
+ * @param {unknown} bruto o conteúdo de um `state.json`
53
+ * @returns {object|null} `null` ("sem estado") quando a entrada não é objeto ou `agents` não é
54
+ * lista; senão `{ crew, status, step: { current, total, label }, agents: [{ id, name, icon,
55
+ * status, label }], handoff, motivo, updatedAtMs }` · `status`: execução `idle` (1.6.x) ou
56
+ * desconhecida vale como `running` · `step.total` e `updatedAtMs`: `null` quando vazios ou
57
+ * inválidos · `handoff`: `{ from, to, message, completedAt }` ou `null` · item de `agents`
58
+ * que não é objeto fica de fora
59
+ */
60
+ export function normalizar(bruto) {
61
+ if (!ehObjeto(bruto) || !Array.isArray(bruto.agents)) return null;
62
+ return {
63
+ crew: texto(bruto.crew),
64
+ status: STATUS_DE_EXECUCAO.includes(bruto.status) ? bruto.status : 'running',
65
+ step: lerPasso(bruto.step),
66
+ agents: bruto.agents.filter(ehObjeto).map(lerAgente),
67
+ handoff: lerPassagem(bruto.handoff),
68
+ motivo: texto(bruto.motivo),
69
+ updatedAtMs: lerData(bruto.updatedAt),
70
+ };
71
+ }
@@ -0,0 +1,81 @@
1
+ // Geometria do escritório: a tela lógica de 320×180 e as 12 mesas, em grade de 4 colunas, pela
2
+ // ordem do elenco. Tudo em pixel lógico e inteiro; a página aplica em porcentagem da tela.
3
+ // Puro: sem DOM, sem relógio, sem rede.
4
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 16, 17 e 19 (repositório do OpenCrew).
5
+
6
+ export const LARGURA = 320;
7
+ export const ALTURA = 180;
8
+ /** Lado do molde do boneco. */
9
+ export const BONECO = 16;
10
+ export const COLUNAS = 4;
11
+ export const LINHAS = 3;
12
+ export const MAX_MESAS = COLUNAS * LINHAS;
13
+ /** Largura de uma coluna: é também a largura do nome e do balão na página. */
14
+ export const LARGURA_COLUNA = LARGURA / COLUNAS;
15
+ /** Altura da parede, no alto da sala; a primeira linha de mesas começa logo abaixo. */
16
+ export const TOPO = 18;
17
+ export const ALTURA_LINHA = (ALTURA - TOPO) / LINHAS;
18
+ /** Tamanho do tampo de toda mesa. */
19
+ export const MESA = Object.freeze({ largura: 44, altura: 11 });
20
+
21
+ // Cada célula (80×54), de cima para baixo: 7 px para o sinal sobre a cabeça, o boneco sentado
22
+ // (16), 1 px de folga, o tampo da mesa (11) e o corredor da frente (19), onde um boneco em pé
23
+ // cabe sem encostar em nada. Nenhum boneco, sentado ou andando pelos vãos, encosta num tampo.
24
+ const SINAL = 7;
25
+ const FOLGA_DO_ASSENTO = 1;
26
+ const FOLGA_DA_FRENTE = 2;
27
+
28
+ function congelar(valor) {
29
+ for (const parte of Object.values(valor)) {
30
+ if (parte && typeof parte === 'object') congelar(parte);
31
+ }
32
+ return Object.freeze(valor);
33
+ }
34
+
35
+ function criarMesa(indice) {
36
+ const coluna = indice % COLUNAS;
37
+ const linha = Math.floor(indice / COLUNAS);
38
+ const centro = coluna * LARGURA_COLUNA + LARGURA_COLUNA / 2;
39
+ const cabeca = TOPO + linha * ALTURA_LINHA + SINAL;
40
+ const tampo = cabeca + BONECO + FOLGA_DO_ASSENTO;
41
+ const chao = tampo + MESA.altura;
42
+ return {
43
+ indice,
44
+ coluna,
45
+ linha,
46
+ retangulo: { x: centro - MESA.largura / 2, y: tampo, ...MESA },
47
+ boneco: { x: centro - BONECO / 2, y: cabeca },
48
+ frente: { x: centro - BONECO / 2, y: chao + FOLGA_DA_FRENTE },
49
+ nome: { x: centro, y: chao + 1 },
50
+ balao: { x: centro, y: cabeca - 1 },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * As 12 mesas, na ordem do elenco (da esquerda para a direita, de cima para baixo). Cada uma:
56
+ * - `indice`, `coluna`, `linha`;
57
+ * - `retangulo` `{ x, y, largura, altura }`: o tampo, que nenhuma caminhada atravessa;
58
+ * - `boneco` `{ x, y }`: canto superior esquerdo do boneco sentado, atrás do tampo;
59
+ * - `frente` `{ x, y }`: canto superior esquerdo do boneco em pé na frente da mesa;
60
+ * - `nome` `{ x, y }`: meio da borda de cima do nome, sob a mesa;
61
+ * - `balao` `{ x, y }`: meio da borda de baixo do balão, sobre a cabeça.
62
+ * A lista e as mesas são imutáveis.
63
+ */
64
+ export const MESAS = congelar(Array.from({ length: MAX_MESAS }, (_, i) => criarMesa(i)));
65
+
66
+ /**
67
+ * X do canto esquerdo de um boneco que anda pelo meio de cada vão vertical, entre duas colunas
68
+ * de mesas. Os vãos horizontais de cada linha ficam atrás das mesas (o `boneco.y`, por onde quem
69
+ * está sentado sai) e na frente delas (o `frente.y`).
70
+ */
71
+ export const VAOS_X = Object.freeze([1, 2, 3].map((coluna) => coluna * LARGURA_COLUNA - BONECO / 2));
72
+
73
+ /** A mesa de quem ocupa a posição `indice` do elenco; `null` do 13º em diante. */
74
+ export function mesaDe(indice) {
75
+ return MESAS[indice] ?? null;
76
+ }
77
+
78
+ /** As mesas de um elenco de `total` agentes: no máximo 12. */
79
+ export function mesas(total) {
80
+ return MESAS.slice(0, Math.max(0, total));
81
+ }
@@ -0,0 +1,95 @@
1
+ // Qual crew está na tela: a memória das consultas ao servidor (o último estado bom de cada
2
+ // crew), a escolha do usuário e a hora de mostrar a demonstração. Cada função devolve uma
3
+ // memória nova; a página guarda a mais recente e pergunta de novo a cada consulta.
4
+ // Puro: sem DOM, sem relógio, sem rede.
5
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 20, 22 e 23 (repositório do OpenCrew).
6
+ import { estadoDaDemo } from './demo.js';
7
+ import { normalizar } from './modelo-estado.js';
8
+ import { TEXTOS } from './modelo-textos.js';
9
+ import { montarModelo } from './modelo-visao.js';
10
+
11
+ /** Crew que some de uma resposta válida continua por este número de consultas; na seguinte, sai. */
12
+ export const CONSULTAS_DE_ESPERA = 3;
13
+
14
+ /**
15
+ * A memória antes da primeira consulta: `crews` é `[{ crew, estado, atualizadoMs, faltas }]`, a
16
+ * de atualização mais recente primeiro (`estado` é o bruto, como veio); `escolhida` é o nome da
17
+ * crew que o usuário escolheu, ou `null`; `falhas` conta as consultas seguidas que falharam.
18
+ */
19
+ export const MEMORIA_INICIAL = Object.freeze({ crews: Object.freeze([]), escolhida: null, falhas: 0 });
20
+
21
+ /** As crews de uma resposta de `/estado`, só as de estado legível; `null` se a resposta é inválida. */
22
+ function lerResposta(resposta) {
23
+ if (resposta === null || typeof resposta !== 'object' || !Array.isArray(resposta.crews)) return null;
24
+ const lidas = [];
25
+ for (const item of resposta.crews) {
26
+ const lido = normalizar(item?.estado);
27
+ const nome = typeof item?.crew === 'string' ? item.crew : '';
28
+ if (lido && nome) lidas.push({ crew: nome, estado: item.estado, atualizadoMs: lido.updatedAtMs ?? 0, faltas: 0 });
29
+ }
30
+ return lidas;
31
+ }
32
+
33
+ /**
34
+ * Junta uma consulta à memória (regra 20).
35
+ * @param {object} memoria a memória anterior
36
+ * @param {unknown} resposta o JSON de `/estado`; `null` (ou qualquer coisa inválida) quando a
37
+ * consulta falhou: aí tudo fica como estava e `falhas` sobe
38
+ * @returns {object} a memória nova; a crew ausente continua, com o último estado bom, por
39
+ * `CONSULTAS_DE_ESPERA` respostas válidas seguidas, e a escolha do usuário cai quando a crew sai
40
+ */
41
+ export function consultar(memoria, resposta) {
42
+ const vindas = lerResposta(resposta);
43
+ if (!vindas) return { ...memoria, falhas: memoria.falhas + 1 };
44
+ const presentes = new Set(vindas.map((c) => c.crew));
45
+ const ausentes = memoria.crews
46
+ .filter((c) => !presentes.has(c.crew) && c.faltas < CONSULTAS_DE_ESPERA)
47
+ .map((c) => ({ ...c, faltas: c.faltas + 1 }));
48
+ const crews = [...vindas, ...ausentes].sort((a, b) => b.atualizadoMs - a.atualizadoMs);
49
+ const escolhida = crews.some((c) => c.crew === memoria.escolhida) ? memoria.escolhida : null;
50
+ return { crews, escolhida, falhas: 0 };
51
+ }
52
+
53
+ /** O usuário escolheu uma crew no seletor (regra 22); nome que não está na memória é ignorado. */
54
+ export function escolher(memoria, crew) {
55
+ return memoria.crews.some((c) => c.crew === crew) ? { ...memoria, escolhida: crew } : memoria;
56
+ }
57
+
58
+ /**
59
+ * O que vai para a tela (regras 22 e 23).
60
+ * @param {object} memoria
61
+ * @param {{ demo?: boolean }} [opcoes] `demo`: o endereço tem `?demo`
62
+ * @returns {{ modo: 'demo'|'real', crew: string|null, estado: object|null, crews: string[], frase: string|null }}
63
+ * `real`: a crew escolhida pelo usuário ou, sem escolha, a de atualização mais recente, com o
64
+ * estado bruto dela; `crews` são os nomes para o seletor · `demo`: sem nenhuma crew, ou com
65
+ * `?demo` (aí sem seletor); `frase` é a da §6 para cada caso
66
+ */
67
+ export function emExibicao(memoria, { demo = false } = {}) {
68
+ const semCrew = { modo: 'demo', crew: null, estado: null };
69
+ if (demo) return { ...semCrew, crews: [], frase: TEXTOS.demoForcada };
70
+ const crews = memoria.crews.map((c) => c.crew);
71
+ if (!crews.length) return { ...semCrew, crews, frase: TEXTOS.demonstracao };
72
+ const atual = memoria.crews.find((c) => c.crew === memoria.escolhida) ?? memoria.crews[0];
73
+ return { modo: 'real', crew: atual.crew, estado: atual.estado, crews, frase: null };
74
+ }
75
+
76
+ /**
77
+ * A página inteira num instante: escolhe a crew (ou a demonstração) e monta o modelo dela.
78
+ * @param {object} memoria
79
+ * @param {object} [opcoes]
80
+ * @param {number} [opcoes.agoraMs] a hora atual, em milissegundos
81
+ * @param {boolean} [opcoes.reduzirMovimento] o sistema pede menos movimento
82
+ * @param {boolean} [opcoes.demo] o endereço tem `?demo`
83
+ * @param {number} [opcoes.decorridoDemoMs] tempo desde que a demonstração entrou na tela
84
+ * @returns {object} o modelo de `montarModelo`, mais `modo` (`demo` ou `real`), `crews` (nomes
85
+ * para o seletor), `frase` (a da demonstração, ou `null`) e `aviso` (o "sem conexão" da §6,
86
+ * ou `null`)
87
+ */
88
+ export function montarPagina(memoria, opcoes = {}) {
89
+ const { agoraMs, reduzirMovimento = false, decorridoDemoMs = 0 } = opcoes;
90
+ const { modo, crew, estado, crews, frase } = emExibicao(memoria, opcoes);
91
+ const demo = modo === 'demo';
92
+ const semConexao = memoria.falhas > 0;
93
+ const modelo = montarModelo(demo ? estadoDaDemo(decorridoDemoMs) : estado, { agoraMs, reduzirMovimento, demo, semConexao, crew });
94
+ return { ...modelo, modo, crews, frase, aviso: semConexao ? TEXTOS.semServidor : null };
95
+ }
@@ -0,0 +1,65 @@
1
+ // Textos que a página mostra, em português do Brasil: os da §6 da spec, o status por extenso de
2
+ // cada agente e o título da aba. A página os grava só por `textContent`.
3
+ // Puro: sem DOM, sem relógio, sem rede.
4
+ // Spec: fase-e1-escritorio-ao-vivo.md, §6 e regras 18, 21, 24 e 29 (repositório do OpenCrew).
5
+
6
+ export const TEXTOS = Object.freeze({
7
+ nome: 'Escritório',
8
+ aguardando: 'Aguardando você',
9
+ abrindo: 'Abrindo o escritório… Se esta mensagem não sumir, abra pelo endereço que o comando /opencrew dashboard mostrou, e não pelo arquivo.',
10
+ semServidor: 'Sem conexão com o escritório. Tentando de novo… Para reabrir, rode /opencrew dashboard.',
11
+ demonstracao: 'Demonstração — nenhuma execução ainda. Rode uma crew e ela aparece aqui.',
12
+ demoForcada: 'Demonstração. Tire ?demo do endereço para ver a sua crew.',
13
+ });
14
+
15
+ /** Status do agente por extenso, para a lista ao lado do desenho (regra 24). */
16
+ export const STATUS_TEXTO = Object.freeze({
17
+ idle: 'Em espera',
18
+ working: 'Trabalhando',
19
+ checkpoint: TEXTOS.aguardando,
20
+ done: 'Concluído',
21
+ skipped: 'Pulado',
22
+ failed: 'Falhou',
23
+ });
24
+
25
+ /** "Passo 2 de 5"; sem total, "Passo 2". */
26
+ export function textoPasso(atual, total) {
27
+ return total ? `Passo ${atual} de ${total}` : `Passo ${atual}`;
28
+ }
29
+
30
+ /** Faixa do checkpoint: "Aguardando você: <rótulo>"; sem rótulo, "Aguardando você". */
31
+ export function textoFaixa(rotulo) {
32
+ return rotulo ? `${TEXTOS.aguardando}: ${rotulo}` : TEXTOS.aguardando;
33
+ }
34
+
35
+ /** "há 19 min" até 59 minutos; depois, em horas inteiras: "há 1 h". */
36
+ function textoIdade(minutos) {
37
+ return minutos <= 59 ? `há ${minutos} min` : `há ${Math.floor(minutos / 60)} h`;
38
+ }
39
+
40
+ export const textoParada = (minutos) => `Última atualização ${textoIdade(minutos)}`;
41
+ export const textoSemSinal = (minutos) => `Sem sinal ${textoIdade(minutos)}`;
42
+
43
+ const TITULO_FIXO = Object.freeze({
44
+ checkpoint: `(!) ${TEXTOS.aguardando}`,
45
+ completed: '✓ Concluída',
46
+ failed: '✗ Falhou',
47
+ });
48
+
49
+ /** "2/5 Escrever"; sem rótulo, "Passo 2 de 5"; sem total saem o "/5" e o " de 5". */
50
+ function tituloDoPasso({ current, total, label }) {
51
+ if (!label) return textoPasso(current, total);
52
+ return `${current}${total ? `/${total}` : ''} ${label}`;
53
+ }
54
+
55
+ /**
56
+ * Título da aba (regra 29), para a página gravar quando mudar.
57
+ * @param {object|null} estado o estado normalizado da crew em exibição; `null`: sem estado
58
+ * @param {{ demo?: boolean, semConexao?: boolean }} [opcoes]
59
+ * @returns {string} "Escritório" em demonstração, sem estado ou sem conexão
60
+ */
61
+ export function titulo(estado, { demo = false, semConexao = false } = {}) {
62
+ if (!estado || demo || semConexao) return TEXTOS.nome;
63
+ const inicio = TITULO_FIXO[estado.status] ?? tituloDoPasso(estado.step);
64
+ return estado.crew ? `${inicio} — ${estado.crew}` : inicio;
65
+ }
@@ -0,0 +1,91 @@
1
+ // O modelo de uma leitura: do estado bruto de uma crew ao que a página desenha e escreve.
2
+ // A hora atual e o "reduzir movimento" entram por parâmetro; nada aqui lê relógio.
3
+ // Puro: sem DOM, sem relógio, sem rede.
4
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 18, 21, 24 e 29 (repositório do OpenCrew).
5
+ import { normalizar } from './modelo-estado.js';
6
+ import { verAgente } from './modelo-agentes.js';
7
+ import { textoFaixa, textoParada, textoPasso, textoSemSinal, titulo } from './modelo-textos.js';
8
+
9
+ const MINUTO = 60000;
10
+ /** Execução `running` sem atualização há mais que isto: a página diz há quanto tempo. */
11
+ export const PARADA_MS = 2 * MINUTO;
12
+ /** Há mais que isto: quem trabalhava recebe a ação `sem-sinal`. */
13
+ export const SEM_SINAL_MS = 20 * MINUTO;
14
+
15
+ /**
16
+ * Regra 21. Só vale para execução `running`, fora da demonstração e com `updatedAt` válido.
17
+ * Nada é regravado: são textos e uma ação derivados da idade, refeitos a cada leitura.
18
+ */
19
+ function sinais(estado, { agoraMs, demo }) {
20
+ const vale = !demo && estado.status === 'running' && estado.updatedAtMs !== null && Number.isFinite(agoraMs);
21
+ const idade = vale ? agoraMs - estado.updatedAtMs : 0;
22
+ const minutos = Math.floor(idade / MINUTO);
23
+ return {
24
+ parada: idade > PARADA_MS ? textoParada(minutos) : null,
25
+ semSinal: idade > SEM_SINAL_MS ? textoSemSinal(minutos) : null,
26
+ };
27
+ }
28
+
29
+ /** A última passagem de bastão, com o nome de quem entregou e de quem recebeu. */
30
+ function verPassagem({ handoff, agents }) {
31
+ if (!handoff) return null;
32
+ const nome = (id) => agents.find((a) => a.id === id)?.name ?? id;
33
+ return {
34
+ de: nome(handoff.from),
35
+ para: nome(handoff.to),
36
+ mensagem: handoff.message,
37
+ deId: handoff.from,
38
+ paraId: handoff.to,
39
+ completedAt: handoff.completedAt,
40
+ };
41
+ }
42
+
43
+ const SEM_ESTADO = Object.freeze({ semEstado: true, crew: '', execucao: null, passo: null, faixa: null, parada: null, passagem: null, motivo: null });
44
+
45
+ function verEstado(estado, { parada, semSinal }, reduzirMovimento) {
46
+ const { current, total, label } = estado.step;
47
+ const contagem = textoPasso(current, total);
48
+ const contexto = { execucao: estado.status, semSinal, reduzirMovimento, balaoDoPasso: label || contagem };
49
+ return {
50
+ semEstado: false,
51
+ crew: estado.crew,
52
+ execucao: estado.status,
53
+ passo: { atual: current, total, rotulo: label, contagem },
54
+ faixa: estado.status === 'checkpoint' ? textoFaixa(label) : null,
55
+ parada,
56
+ agentes: estado.agents.map((agente, i) => verAgente(agente, i, contexto)),
57
+ passagem: verPassagem(estado),
58
+ motivo: estado.status === 'failed' && estado.motivo ? estado.motivo : null,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * O que a página mostra de uma crew. Nunca lança erro e não altera o objeto recebido.
64
+ * @param {unknown} bruto o estado como veio do servidor (ou da demonstração)
65
+ * @param {object} [opcoes]
66
+ * @param {number} [opcoes.agoraMs] a hora atual, em milissegundos; sem ela não há "sem sinal"
67
+ * @param {boolean} [opcoes.reduzirMovimento] o sistema pede menos movimento
68
+ * @param {boolean} [opcoes.demo] o estado é o da demonstração
69
+ * @param {boolean} [opcoes.semConexao] a última consulta ao servidor falhou
70
+ * @param {string} [opcoes.crew] o nome com que o servidor lista a crew; vale mais que o do estado
71
+ * @returns {object} `{ semEstado, demo, semConexao, reduzirMovimento, titulo, crew, execucao,
72
+ * passo, faixa, parada, agentes, passagem, motivo }` · `semEstado`: a entrada não era um
73
+ * estado (aí `agentes` é vazio e o resto, `null`) · `execucao`: `running`, `checkpoint`,
74
+ * `completed` ou `failed` · `passo`: `{ atual, total, rotulo, contagem }` · `faixa`: o texto do
75
+ * checkpoint ou `null` · `parada`: "Última atualização há …" ou `null` · `agentes`: todos, na
76
+ * ordem do elenco (ver `verAgente`); é a lista ao lado do desenho, e quem tem `mesa` é
77
+ * desenhado · `passagem`: `{ de, para, mensagem, deId, paraId, completedAt }` ou `null` ·
78
+ * `motivo`: só em execução `failed`
79
+ */
80
+ export function montarModelo(bruto, opcoes = {}) {
81
+ const { reduzirMovimento = false, demo = false, semConexao = false } = opcoes;
82
+ const base = { demo, semConexao, reduzirMovimento };
83
+ const lido = normalizar(bruto);
84
+ if (!lido) return { ...SEM_ESTADO, ...base, agentes: [], titulo: titulo(null) };
85
+ const estado = { ...lido, crew: opcoes.crew || lido.crew };
86
+ return {
87
+ ...verEstado(estado, sinais(estado, opcoes), reduzirMovimento),
88
+ ...base,
89
+ titulo: titulo(estado, { demo, semConexao }),
90
+ };
91
+ }