up-cc 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "up-cc",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Spec-driven development for Claude Code, Gemini, OpenCode and OpenAI Codex. Brainstorm-first, GitHub-native, TDD por tipo.",
5
5
  "bin": {
6
6
  "up-cc": "up/bin/install.js"
package/up/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ Todas as mudancas relevantes do `up-cc` ficam documentadas aqui. O formato segue
4
4
  o espirito de [Keep a Changelog](https://keepachangelog.com/) e o versionamento
5
5
  e [SemVer](https://semver.org/). v2.0.0 e um **major** (breaking change).
6
6
 
7
+ ## 2.2.0
8
+
9
+ > O `.plano/` sempre sobreviveu ao `/clear` no DISCO, mas a RETOMADA era pull: o
10
+ > hook reinjetava so a doutrina e o agente tinha que escolher ler o STATE.md. Agora
11
+ > e push: o SessionStart injeta a posicao atual sozinho.
12
+
13
+ ### Corrigido
14
+
15
+ - **SessionStart agora injeta `.plano/STATE.md`, nao so a doutrina.** O hook
16
+ `up-session-start.js` le `<cwd>/.plano/STATE.md` (cwd do payload, fallback
17
+ `process.cwd()`) e injeta um trecho capado (2800 chars) num bloco
18
+ `<UP-ESTADO-ATUAL>` junto do bootstrap. Resultado: a cada `/clear` (e no
19
+ startup) a posicao do projeto volta pro contexto **deterministicamente**, sem
20
+ depender do agente escolher ler. Resolve o "as vezes nao sobrevive ao /clear".
21
+ Fail-open mantido: sem `.plano`, so a doutrina (comportamento anterior). Head
22
+ bruto, independe do formato PT/EN do STATE.md.
23
+
24
+ ### Testes
25
+
26
+ - `up/hooks/up-session-start.test.cjs`: 5 casos red-green invocando o hook como
27
+ subprocesso (stdin->stdout), cobrindo clear/startup com e sem `.plano`, skip de
28
+ `resume`, e cap de STATE.md gigante.
29
+
30
+ ### Conhecido (nao corrigido aqui)
31
+
32
+ - `up-tools state-snapshot` parseia rotulos em INGLES e os STATE.md reais sao em
33
+ PORTUGUES, entao retorna campos null em projeto real. O hook NAO usa
34
+ state-snapshot (le STATE.md direto), entao a retomada nao e afetada. Fica
35
+ registrado pra um fix futuro do parser.
36
+
7
37
  ## 2.1.0
8
38
 
9
39
  > GitHub e a interacao humana viram **eixos separados**. Antes, `--solo` desligava o
@@ -53,20 +53,59 @@ function readBootstrap() {
53
53
  return FALLBACK_BOOTSTRAP;
54
54
  }
55
55
 
56
- function buildContext(bootstrap) {
57
- return '<EXTREMELY_IMPORTANT>\n' +
56
+ // Teto do trecho de STATE.md injetado. As secoes uteis (Posicao Atual,
57
+ // Contexto Acumulado, Continuidade de Sessao) vivem no topo do arquivo, entao
58
+ // um head capado retoma a posicao sem estourar o contexto.
59
+ const MAX_STATE_CHARS = 2800;
60
+
61
+ /**
62
+ * Le um resumo do estado do projeto em <cwd>/.plano/STATE.md, se existir.
63
+ * Retorna o trecho capado (string) ou null. PUSH deterministico da posicao:
64
+ * o agente sempre volta sabendo onde parou, sem depender de escolher ler.
65
+ * Independe do formato exato (PT/EN) -- e um head bruto, nunca quebra.
66
+ */
67
+ function readState(cwd) {
68
+ if (!cwd || typeof cwd !== 'string') return null;
69
+ try {
70
+ const statePath = path.join(cwd, '.plano', 'STATE.md');
71
+ if (!fs.existsSync(statePath)) return null;
72
+ let content = fs.readFileSync(statePath, 'utf8');
73
+ if (!content || !content.trim()) return null;
74
+ content = content.trim();
75
+ if (content.length > MAX_STATE_CHARS) {
76
+ content = content.slice(0, MAX_STATE_CHARS).trimEnd() +
77
+ '\n\n[...truncado -- leia .plano/STATE.md para o resto]';
78
+ }
79
+ return content;
80
+ } catch (e) {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function buildContext(bootstrap, state) {
86
+ let ctx = '<EXTREMELY_IMPORTANT>\n' +
58
87
  'Voce tem o UP.\n\n' +
59
88
  'Abaixo esta o conteudo completo da sua skill \'usando-up\' -- sua introducao a usar ' +
60
89
  'as skills do UP. Para todas as outras skills, use a tool Skill:\n\n' +
61
90
  bootstrap.trim() + '\n' +
62
91
  '</EXTREMELY_IMPORTANT>';
92
+
93
+ if (state) {
94
+ ctx += '\n\n<UP-ESTADO-ATUAL>\n' +
95
+ 'Voce esta retomando um projeto UP em andamento (inicio de sessao ou apos /clear). ' +
96
+ 'NAO comece do zero nem assuma contexto perdido: esta e a posicao atual, lida de ' +
97
+ '.plano/STATE.md. Continue daqui. Para rotear o proximo passo, use /up sem argumento.\n\n' +
98
+ state + '\n' +
99
+ '</UP-ESTADO-ATUAL>';
100
+ }
101
+ return ctx;
63
102
  }
64
103
 
65
- function emit(bootstrap) {
104
+ function emit(bootstrap, state) {
66
105
  const output = {
67
106
  hookSpecificOutput: {
68
107
  hookEventName: 'SessionStart',
69
- additionalContext: buildContext(bootstrap),
108
+ additionalContext: buildContext(bootstrap, state || null),
70
109
  },
71
110
  };
72
111
  process.stdout.write(JSON.stringify(output));
@@ -74,8 +113,9 @@ function emit(bootstrap) {
74
113
 
75
114
  let input = '';
76
115
  const stdinTimeout = setTimeout(() => {
77
- // No stdin within 3s (Windows/Git Bash guard): still inject the bootstrap.
78
- try { emit(readBootstrap()); } catch (e) {}
116
+ // No stdin within 3s (Windows/Git Bash guard): inject the bootstrap.
117
+ // Sem payload nao ha cwd, entao sem bloco de estado (so a doutrina).
118
+ try { emit(readBootstrap(), null); } catch (e) {}
79
119
  process.exit(0);
80
120
  }, 3000);
81
121
 
@@ -85,10 +125,13 @@ process.stdin.on('end', () => {
85
125
  clearTimeout(stdinTimeout);
86
126
  try {
87
127
  let source = null;
128
+ let cwd = null;
88
129
  try {
89
130
  const data = JSON.parse(input);
90
- // SessionStart payload carries the trigger in `source` (startup|clear|compact|resume).
131
+ // SessionStart payload carries the trigger in `source` (startup|clear|compact|resume)
132
+ // e o diretorio do projeto em `cwd`.
91
133
  source = data && (data.source || data.hook_event_source || null);
134
+ cwd = (data && data.cwd) || null;
92
135
  } catch (e) {
93
136
  // No/invalid payload: with no matcher available, inject anyway.
94
137
  source = null;
@@ -99,7 +142,9 @@ process.stdin.on('end', () => {
99
142
  process.exit(0);
100
143
  }
101
144
 
102
- emit(readBootstrap());
145
+ // Sem cwd no payload, cai pro diretorio do processo (o harness roda o hook no cwd da sessao).
146
+ const projectCwd = cwd || process.cwd();
147
+ emit(readBootstrap(), readState(projectCwd));
103
148
  } catch (e) {
104
149
  // Fail-open: never block the session.
105
150
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * up-session-start.test.cjs - testa a injecao de estado no SessionStart.
3
+ * Roda: node up/hooks/up-session-start.test.cjs
4
+ * Invoca o hook como subprocesso (stdin JSON -> stdout JSON), igual o harness faz.
5
+ */
6
+ const assert = require('assert');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { execFileSync } = require('child_process');
11
+
12
+ const HOOK = path.resolve(__dirname, 'up-session-start.js');
13
+
14
+ /** Roda o hook com um payload e devolve {additionalContext} ou null. */
15
+ function runHook(payload) {
16
+ const out = execFileSync('node', [HOOK], {
17
+ input: JSON.stringify(payload),
18
+ encoding: 'utf-8',
19
+ timeout: 8000,
20
+ });
21
+ if (!out || !out.trim()) return null;
22
+ const parsed = JSON.parse(out);
23
+ return parsed.hookSpecificOutput ? parsed.hookSpecificOutput.additionalContext : null;
24
+ }
25
+
26
+ function mkProjectWithState(stateBody) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'up-ss-'));
28
+ fs.mkdirSync(path.join(dir, '.plano'), { recursive: true });
29
+ fs.writeFileSync(path.join(dir, '.plano', 'STATE.md'), stateBody, 'utf-8');
30
+ return dir;
31
+ }
32
+ function mkProjectNoPlano() {
33
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'up-ss-noplano-'));
34
+ }
35
+
36
+ let pass = 0, fail = 0;
37
+ function t(name, fn) {
38
+ try { fn(); console.log(' ok -', name); pass++; }
39
+ catch (e) { console.error(' FAIL -', name, '\n ', e.message); fail++; }
40
+ }
41
+
42
+ const SAMPLE_STATE = `# Estado do Projeto
43
+
44
+ ## Posicao Atual
45
+
46
+ Fase: 7 de 10 (Comando /up:melhorias)
47
+ Status: In progress
48
+ Ultima atividade: 2026-06-19 -- T3 do plano 02 concluido
49
+
50
+ ## Continuidade de Sessao
51
+
52
+ Parou em: implementando o gate de aprovacao visual
53
+ `;
54
+
55
+ // 1. clear + .plano/STATE.md presente -> injeta bootstrap E o estado
56
+ t('clear com .plano: injeta bootstrap + estado', () => {
57
+ const dir = mkProjectWithState(SAMPLE_STATE);
58
+ const ctx = runHook({ source: 'clear', cwd: dir, hook_event_name: 'SessionStart' });
59
+ assert.ok(ctx, 'deve haver additionalContext');
60
+ assert.ok(/Voce tem o UP/.test(ctx), 'deve conter a doutrina (bootstrap)');
61
+ assert.ok(/Fase: 7 de 10/.test(ctx), 'deve conter a posicao do STATE.md');
62
+ assert.ok(/Parou em: implementando o gate/.test(ctx), 'deve conter onde parou');
63
+ });
64
+
65
+ // 2. clear SEM .plano -> so bootstrap, sem bloco de estado
66
+ t('clear sem .plano: so bootstrap', () => {
67
+ const dir = mkProjectNoPlano();
68
+ const ctx = runHook({ source: 'clear', cwd: dir, hook_event_name: 'SessionStart' });
69
+ assert.ok(ctx, 'deve haver additionalContext');
70
+ assert.ok(/Voce tem o UP/.test(ctx), 'deve conter a doutrina');
71
+ assert.ok(!/UP-ESTADO-ATUAL/.test(ctx), 'NAO deve ter bloco de estado sem .plano');
72
+ });
73
+
74
+ // 3. startup tambem injeta estado (retomar projeto ao abrir)
75
+ t('startup com .plano: injeta estado', () => {
76
+ const dir = mkProjectWithState(SAMPLE_STATE);
77
+ const ctx = runHook({ source: 'startup', cwd: dir, hook_event_name: 'SessionStart' });
78
+ assert.ok(/Fase: 7 de 10/.test(ctx), 'startup tambem deve trazer o estado');
79
+ });
80
+
81
+ // 4. source fora dos triggers (resume) -> nada
82
+ t('resume: nenhum output', () => {
83
+ const dir = mkProjectWithState(SAMPLE_STATE);
84
+ const ctx = runHook({ source: 'resume', cwd: dir, hook_event_name: 'SessionStart' });
85
+ assert.strictEqual(ctx, null, 'resume nao deve injetar nada');
86
+ });
87
+
88
+ // 5. STATE.md gigante -> capado (bloco de estado nao explode o contexto)
89
+ t('STATE.md gigante: capado', () => {
90
+ const huge = SAMPLE_STATE + '\n' + 'x'.repeat(20000);
91
+ const dir = mkProjectWithState(huge);
92
+ const ctx = runHook({ source: 'clear', cwd: dir, hook_event_name: 'SessionStart' });
93
+ // o bloco de estado deve estar truncado bem abaixo do tamanho do STATE gigante
94
+ assert.ok(ctx.length < 8000, 'contexto total deve ficar capado (<8000 chars), veio ' + ctx.length);
95
+ assert.ok(/Fase: 7 de 10/.test(ctx), 'mesmo capado, a posicao do topo deve aparecer');
96
+ });
97
+
98
+ console.log(`\n${pass} passed, ${fail} failed`);
99
+ process.exit(fail ? 1 : 0);