wendkeep 0.8.1 → 0.9.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.9.1] — 2026-07-06
8
+
9
+ Interactive install UX: language first.
10
+
11
+ ### Added
12
+ - **`wendkeep init` asks the vault language first** on an interactive TTY (when `--locale`
13
+ isn't passed): `[1] Português [2] English`. The answer drives the folders, scaffold and
14
+ skills — and the remaining prompts (vault path, companion selection) now render in the
15
+ chosen locale instead of always Portuguese. `--yes`, `--locale` and non-TTY are unchanged.
16
+
17
+ ## [0.9.0] — 2026-07-06
18
+
19
+ Engineering debt: sensor editing + i18n coherence for auto-generated notes.
20
+
21
+ ### Added
22
+ - **`wendkeep sensors add <id> "<command>"`** (`--severity` / `--type` / `--report` / `--name`
23
+ / `--description`) — append a sensor to `wendkeep.sensors.json` (creates the file with
24
+ `$schema` when absent, dedups by id) instead of hand-editing JSON.
25
+ - **Locale-aware derived notes:** the auto-generated bug/decision/learning notes render their
26
+ headings + callout in the vault locale — an `en` vault no longer gets Portuguese headings.
27
+
28
+ ### Deferred (with reason)
29
+ - `migrate-locale`: renaming a populated vault breaks every wikilink to the old folder names;
30
+ needs a backlink-repair pass — its own effort, not a patch.
31
+ - Code-hash verdict freshness: a change carries no file manifest, so "the code" is undefined;
32
+ the existing `tarefas.md` hash already blocks task drift.
33
+
7
34
  ## [0.8.1] — 2026-07-06
8
35
 
9
36
  Polish: i18n coherence + presentation.
@@ -196,6 +223,8 @@ Initial release — the capture engine, extracted from a system in daily product
196
223
  - `wendkeep init` (cross-platform installer) + optional `@bitbonsai/mcpvault` MCP server.
197
224
 
198
225
  <!-- Only v0.4.0+ is tagged in git (history starts here); older versions link to npm. -->
226
+ [0.9.1]: https://github.com/rogersialves/wendkeep/releases/tag/v0.9.1
227
+ [0.9.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.9.0
199
228
  [0.8.1]: https://github.com/rogersialves/wendkeep/releases/tag/v0.8.1
200
229
  [0.8.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.8.0
201
230
  [0.7.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.7.0
package/bin/wendkeep.mjs CHANGED
@@ -46,7 +46,7 @@ Usage:
46
46
  wendkeep change <sub> Change lifecycle: new [--simple] | list | show | status |
47
47
  done <id> | undone <id> | diff | archive [--force].
48
48
  wendkeep spec <sub> Living specs: list | show <capability>.
49
- wendkeep sensors list Show the sensors from wendkeep.sensors.json.
49
+ wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
50
50
  wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
51
51
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
52
52
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
@@ -162,7 +162,28 @@ export function extractBugDetails(tx) {
162
162
  };
163
163
  }
164
164
 
165
- export function buildBugNoteContent(bug, issueRef, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(bug.rootCause)) {
165
+ // Locale labels for the auto-generated derived notes (0.9.0). Output-only the extraction
166
+ // heuristics are untouched. Default pt-BR keeps existing behaviour for every legacy caller.
167
+ const NOTE_LABELS = {
168
+ 'pt-BR': {
169
+ autoTag: 'Auto-gerada', autoLine: (p) => `Nota criada automaticamente pelo hook Stop do ${p}.`, session: 'Sessão',
170
+ verify: '_Extraído da sessão — verificar._', complete: '_Extraído da sessão — complementar._',
171
+ bug: { symptom: 'Sintoma', rootCause: 'Causa raiz', fix: 'Correção', files: 'Arquivos alterados', evidence: 'Evidência', lessons: 'Lições aprendidas', noFix: '_Nenhuma correção explícita detectada._', seeSession: '_Ver sessão vinculada._', addEvidence: '_Adicionar evidência empírica._' },
172
+ dec: { context: 'Contexto', decision: 'Decisão', consequences: 'Consequências', alternatives: 'Alternativas consideradas', noAlt: '_Nenhuma alternativa registrada automaticamente._' },
173
+ learn: { title: 'Aprendizado', context: 'Contexto', learned: 'O que aprendemos', future: 'Como aplicar no futuro', futureHint: '_Registrar como este conhecimento pode ser reutilizado._' },
174
+ },
175
+ en: {
176
+ autoTag: 'Auto-generated', autoLine: (p) => `Note created automatically by the ${p} Stop hook.`, session: 'Session',
177
+ verify: '_Extracted from the session — verify._', complete: '_Extracted from the session — complete._',
178
+ bug: { symptom: 'Symptom', rootCause: 'Root cause', fix: 'Fix', files: 'Changed files', evidence: 'Evidence', lessons: 'Lessons learned', noFix: '_No explicit fix detected._', seeSession: '_See the linked session._', addEvidence: '_Add empirical evidence._' },
179
+ dec: { context: 'Context', decision: 'Decision', consequences: 'Consequences', alternatives: 'Alternatives considered', noAlt: '_No alternative recorded automatically._' },
180
+ learn: { title: 'Learning', context: 'Context', learned: 'What we learned', future: 'How to apply in future', futureHint: '_Record how this knowledge can be reused._' },
181
+ },
182
+ };
183
+ function noteLabels(localeId) { return NOTE_LABELS[localeId] || NOTE_LABELS['pt-BR']; }
184
+
185
+ export function buildBugNoteContent(bug, issueRef, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(bug.rootCause), localeId = 'pt-BR') {
186
+ const L = noteLabels(localeId);
166
187
  const title = issueRef
167
188
  ? `${issueRef} - ${normalizeInline(bug.rootCause, 80)}`
168
189
  : normalizeInline(bug.rootCause, 80);
@@ -184,31 +205,31 @@ issue: ${yamlQuote(issueRef || '')}
184
205
 
185
206
  # Bug - ${title}
186
207
 
187
- > [!note] Auto-gerada
188
- > Nota criada automaticamente pelo hook Stop do ${provider.label}.
189
- > Sessão: ${wikilinkFromRel(sessionRel)}
208
+ > [!note] ${L.autoTag}
209
+ > ${L.autoLine(provider.label)}
210
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
190
211
 
191
- ## Sintoma
212
+ ## ${L.bug.symptom}
192
213
 
193
- ${bug.symptom || '_Extraído da sessão — verificar._'}
214
+ ${bug.symptom || L.verify}
194
215
 
195
- ## Causa raiz
216
+ ## ${L.bug.rootCause}
196
217
 
197
218
  ${bug.rootCause}
198
219
 
199
- ## Correção
220
+ ## ${L.bug.fix}
200
221
 
201
- ${markdownList(bug.fixes, '_Nenhuma correção explícita detectada._')}
222
+ ${markdownList(bug.fixes, L.bug.noFix)}
202
223
 
203
- ## Arquivos alterados
224
+ ## ${L.bug.files}
204
225
 
205
- ${markdownList(bug.changedFiles.map((file) => `\`${file}\``), '_Ver sessão vinculada._')}
226
+ ${markdownList(bug.changedFiles.map((file) => `\`${file}\``), L.bug.seeSession)}
206
227
 
207
- ## Evidência
228
+ ## ${L.bug.evidence}
208
229
 
209
- ${markdownList(bug.evidence, '_Adicionar evidência empírica._')}
230
+ ${markdownList(bug.evidence, L.bug.addEvidence)}
210
231
 
211
- ## Lições aprendidas
232
+ ## ${L.bug.lessons}
212
233
 
213
234
  ${bug.lessons}
214
235
  `;
@@ -309,7 +330,8 @@ export function extractDecisionDetails(tx) {
309
330
  };
310
331
  }
311
332
 
312
- export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title)) {
333
+ export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title), localeId = 'pt-BR') {
334
+ const L = noteLabels(localeId);
313
335
  const adrId = `ADR-${String(adrNum).padStart(3, '0')}`;
314
336
  return `---
315
337
  type: decision
@@ -327,25 +349,25 @@ superseded_by: ""
327
349
 
328
350
  # ${adrId} - ${decision.title}
329
351
 
330
- > [!note] Auto-gerada
331
- > Nota criada automaticamente pelo hook Stop do ${provider.label}.
332
- > Sessão: ${wikilinkFromRel(sessionRel)}
352
+ > [!note] ${L.autoTag}
353
+ > ${L.autoLine(provider.label)}
354
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
333
355
 
334
- ## Contexto
356
+ ## ${L.dec.context}
335
357
 
336
- ${decision.context || '_Extraído da sessão — complementar._'}
358
+ ${decision.context || L.complete}
337
359
 
338
- ## Decisão
360
+ ## ${L.dec.decision}
339
361
 
340
362
  ${decision.detail}
341
363
 
342
- ## Consequências
364
+ ## ${L.dec.consequences}
343
365
 
344
366
  ${decision.consequences}
345
367
 
346
- ## Alternativas consideradas
368
+ ## ${L.dec.alternatives}
347
369
 
348
- ${markdownList(decision.alternatives, '_Nenhuma alternativa registrada automaticamente._')}
370
+ ${markdownList(decision.alternatives, L.dec.noAlt)}
349
371
  `;
350
372
  }
351
373
 
@@ -413,7 +435,8 @@ export function extractLearningDetails(tx, bugDetails) {
413
435
  return learnings.length ? learnings.slice(0, 5) : null;
414
436
  }
415
437
 
416
- export function buildLearningNoteContent(learning, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(learning.title)) {
438
+ export function buildLearningNoteContent(learning, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(learning.title), localeId = 'pt-BR') {
439
+ const L = noteLabels(localeId);
417
440
  return `---
418
441
  type: learning
419
442
  date: ${dateStr}
@@ -427,23 +450,23 @@ tags:
427
450
  ${yamlTags(learning.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
428
451
  ---
429
452
 
430
- # Aprendizado - ${learning.title}
453
+ # ${L.learn.title} - ${learning.title}
431
454
 
432
- > [!note] Auto-gerada
433
- > Nota criada automaticamente pelo hook Stop do ${provider.label}.
434
- > Sessão: ${wikilinkFromRel(sessionRel)}
455
+ > [!note] ${L.autoTag}
456
+ > ${L.autoLine(provider.label)}
457
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
435
458
 
436
- ## Contexto
459
+ ## ${L.learn.context}
437
460
 
438
- ${learning.context || '_Extraído da sessão — complementar._'}
461
+ ${learning.context || L.complete}
439
462
 
440
- ## O que aprendemos
463
+ ## ${L.learn.learned}
441
464
 
442
465
  ${learning.content}
443
466
 
444
- ## Como aplicar no futuro
467
+ ## ${L.learn.future}
445
468
 
446
- _Registrar como este conhecimento pode ser reutilizado._
469
+ ${L.learn.futureHint}
447
470
  `;
448
471
  }
449
472
 
@@ -478,7 +501,8 @@ function alreadyHasKey(keys, candidate) {
478
501
  export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
479
502
  const linked = { decisions: [], bugs: [], learnings: [] };
480
503
  const provider = providerMeta(options.provider);
481
- const locF = getLocale(vaultBase).folders;
504
+ const loc = getLocale(vaultBase);
505
+ const locF = loc.folders;
482
506
  const bugsDir = join(vaultBase, monthFolderRelFromDateStr(locF.bugs, dateStr, vaultBase));
483
507
  const decisionsDir = join(vaultBase, monthFolderRelFromDateStr(locF.decisions, dateStr, vaultBase));
484
508
  const learningsDir = join(vaultBase, monthFolderRelFromDateStr(locF.learnings, dateStr, vaultBase));
@@ -497,7 +521,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
497
521
  const causeSlug = slugify(bugDetails.rootCause.slice(0, 40), 'bug');
498
522
  const fileName = issueRef ? `${issueRef}-${causeSlug}.md` : `${dateStr}-bug-${causeSlug}.md`;
499
523
  const filePath = join(bugsDir, fileName);
500
- if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey), 'utf-8');
524
+ if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey, loc.id), 'utf-8');
501
525
  linked.bugs.push(toVaultRelative(vaultBase, filePath));
502
526
  existingKeys.bugs.push(bugKey);
503
527
  }
@@ -513,7 +537,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
513
537
  const filePath = join(decisionsDir, fileName);
514
538
  if (!existsSync(filePath)) {
515
539
  const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
516
- writeFileSync(filePath, buildDecisionNoteContent(decisionDetails, adrNum, dateStr, sessionRel, provider, decisionKey), 'utf-8');
540
+ writeFileSync(filePath, buildDecisionNoteContent(decisionDetails, adrNum, dateStr, sessionRel, provider, decisionKey, loc.id), 'utf-8');
517
541
  }
518
542
  linked.decisions.push(toVaultRelative(vaultBase, filePath));
519
543
  existingKeys.decisions.push(decisionKey);
@@ -528,7 +552,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
528
552
  const learningSlug = slugify(learning.title.slice(0, 40), 'aprendizado');
529
553
  const fileName = `${dateStr}-${learningSlug}.md`;
530
554
  const filePath = join(learningsDir, fileName);
531
- if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey), 'utf-8');
555
+ if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey, loc.id), 'utf-8');
532
556
  linked.learnings.push(toVaultRelative(vaultBase, filePath));
533
557
  existingKeys.learnings.push(learningKey);
534
558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
  import readline from 'node:readline';
5
5
 
6
6
  const HINT = 'Espaço marca/desmarca · ↑/↓ move · a=todos · n=nenhum · Enter confirma';
7
+ const HEADER = 'Companions';
7
8
 
8
9
  export function initialCompanionMenu(companions) {
9
10
  return {
@@ -35,8 +36,8 @@ export function reduceCompanionMenu(state, action) {
35
36
  }
36
37
  }
37
38
 
38
- export function renderCompanionMenu(state) {
39
- const lines = [`Companions — ${HINT}:`];
39
+ export function renderCompanionMenu(state, { hint = HINT, header = HEADER } = {}) {
40
+ const lines = [`${header} — ${hint}:`];
40
41
  state.items.forEach((it, i) => {
41
42
  const cursor = i === state.cursor ? '>' : ' ';
42
43
  const box = it.checked ? '[x]' : '[ ]';
@@ -63,7 +64,7 @@ export function canInteractiveSelect(input = process.stdin, output = process.std
63
64
 
64
65
  // Run the raw-TTY checkbox menu; resolves with the selected ids. Caller must check
65
66
  // canInteractiveSelect() first (otherwise use the text fallback).
66
- export function selectCompanionsInteractive(companions, { input = process.stdin, output = process.stdout } = {}) {
67
+ export function selectCompanionsInteractive(companions, { input = process.stdin, output = process.stdout, labels } = {}) {
67
68
  return new Promise((resolve) => {
68
69
  let state = initialCompanionMenu(companions);
69
70
  let drawnLines = 0;
@@ -77,7 +78,7 @@ export function selectCompanionsInteractive(companions, { input = process.stdin,
77
78
  readline.moveCursor(output, 0, -drawnLines);
78
79
  readline.clearScreenDown(output);
79
80
  }
80
- const text = renderCompanionMenu(state);
81
+ const text = renderCompanionMenu(state, labels);
81
82
  output.write(`${text}\n`);
82
83
  drawnLines = text.split('\n').length;
83
84
  };
package/src/init.mjs CHANGED
@@ -192,17 +192,55 @@ function installVaultColors(vaultPath) {
192
192
 
193
193
  // --- main -------------------------------------------------------------------
194
194
 
195
+ // Interactive prompt strings by locale. The language question itself is bilingual (asked
196
+ // before the locale is known); everything after follows the answer.
197
+ const PROMPTS = {
198
+ 'pt-BR': {
199
+ vault: (f) => `Caminho do vault Obsidian (Enter aceita o padrão, ou digite outro)\n [${f}]\n> `,
200
+ companionsHeader: '\nCompanions (plugins/MCP opcionais — context-mode é a principal):',
201
+ companionsAsk: (def) => `Digite os ids separados por vírgula (Enter aceita [${def}], "none" p/ nenhum): `,
202
+ menu: { hint: 'Espaço marca/desmarca · ↑/↓ move · a=todos · n=nenhum · Enter confirma', header: 'Companions' },
203
+ },
204
+ en: {
205
+ vault: (f) => `Obsidian vault path (Enter for the default, or type another)\n [${f}]\n> `,
206
+ companionsHeader: '\nCompanions (optional plugins/MCP — context-mode is the main one):',
207
+ companionsAsk: (def) => `Enter ids comma-separated (Enter for [${def}], "none" for none): `,
208
+ menu: { hint: 'Space toggles · ↑/↓ move · a=all · n=none · Enter confirms', header: 'Companions' },
209
+ },
210
+ };
211
+
212
+ export function promptStrings(localeId) {
213
+ return PROMPTS[localeId] || PROMPTS['pt-BR'];
214
+ }
215
+
216
+ // Map the language answer to a locale id. 2/en → en; 1/pt/empty/unknown → pt-BR.
217
+ export function parseLocaleAnswer(ans) {
218
+ const a = String(ans || '').trim().toLowerCase();
219
+ if (a === '2' || a === 'en' || a === 'english') return 'en';
220
+ return 'pt-BR';
221
+ }
222
+
195
223
  export async function runInit(argv) {
196
224
  const args = parseArgs(argv);
197
225
  const projectPath = resolve(args.project || process.cwd());
198
226
  const log = (s) => process.stdout.write(`${s}\n`);
199
227
 
228
+ // Language first (i18n): an interactive TTY without --locale is asked the vault language;
229
+ // folders, prompts and scaffold all follow the answer.
230
+ if (!args.locale && process.stdin.isTTY && !args.yes) {
231
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
232
+ const ans = await rl.question('Idioma do vault / Vault language:\n [1] Português (pt-BR) [2] English (en)\n> ');
233
+ rl.close();
234
+ args.locale = parseLocaleAnswer(ans);
235
+ }
236
+ const P = promptStrings(args.locale && LOCALES[args.locale] ? args.locale : DEFAULT_LOCALE);
237
+
200
238
  let vaultPath = args.vault;
201
239
  if (!vaultPath) {
202
240
  const fallback = join(projectPath, deriveVaultDirName(projectPath));
203
241
  if (process.stdin.isTTY && !args.yes) {
204
242
  const rl = createInterface({ input: process.stdin, output: process.stdout });
205
- const ans = (await rl.question(`Obsidian vault path (Enter aceita o padrão, ou digite outro caminho)\n [${fallback}]\n> `)).trim();
243
+ const ans = (await rl.question(P.vault(fallback))).trim();
206
244
  rl.close();
207
245
  vaultPath = ans || fallback;
208
246
  } else {
@@ -222,16 +260,14 @@ export async function runInit(argv) {
222
260
  } else if (process.stdin.isTTY && !args.yes) {
223
261
  if (canInteractiveSelect()) {
224
262
  log(''); // the checkbox menu renders its own header
225
- companions = await selectCompanionsInteractive(COMPANIONS);
263
+ companions = await selectCompanionsInteractive(COMPANIONS, { labels: P.menu });
226
264
  } else {
227
265
  // Text fallback (no raw-mode TTY): list + comma input with clear instructions.
228
- log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
266
+ log(P.companionsHeader);
229
267
  for (const c of COMPANIONS) log(` ${c.default ? '[x]' : '[ ]'} ${c.label}`);
230
268
  const def = resolveCompanions({}).join(',');
231
269
  const rl = createInterface({ input: process.stdin, output: process.stdout });
232
- const ans = (await rl.question(
233
- `Digite os ids separados por vírgula (Enter aceita [${def}], "none" p/ nenhum): `,
234
- )).trim();
270
+ const ans = (await rl.question(P.companionsAsk(def))).trim();
235
271
  rl.close();
236
272
  companions = ans.toLowerCase() === 'none' ? [] : resolveCompanions({ companionsFlag: ans || def });
237
273
  }
package/src/sensors.mjs CHANGED
@@ -1,23 +1,55 @@
1
- // `wendkeep sensors list`read-only view over wendkeep.sensors.json (0.7.0).
2
- import { resolve } from 'node:path';
1
+ // `wendkeep sensors <list|add>` — view/edit wendkeep.sensors.json (0.7.0 / 0.9.0).
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
3
4
  import { loadSensors } from '../hooks/sensors-core.mjs';
4
5
 
6
+ const SCHEMA = 'https://raw.githubusercontent.com/rogersialves/wendkeep/main/schema/wendkeep.sensors.schema.json';
7
+
8
+ function opt(argv, name) {
9
+ const i = argv.indexOf(name);
10
+ if (i >= 0) return argv[i + 1];
11
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
12
+ return eq ? eq.slice(name.length + 1) : undefined;
13
+ }
14
+
15
+ function resolveProject(rest) {
16
+ return resolve(opt(rest, '--project') || process.cwd());
17
+ }
18
+
5
19
  export function runSensors(argv) {
6
20
  const [sub, ...rest] = argv;
7
- if (sub !== 'list') {
8
- process.stderr.write(`wendkeep sensors: unknown subcommand "${sub}". Known: list.\n`);
9
- process.exit(2);
10
- }
11
- let project;
12
- for (let i = 0; i < rest.length; i += 1) {
13
- if (rest[i] === '--project') project = rest[++i];
14
- else if (rest[i].startsWith('--project=')) project = rest[i].slice(10);
21
+
22
+ if (sub === 'list') {
23
+ const sensors = loadSensors(resolveProject(rest));
24
+ if (!sensors.length) { process.stdout.write('sensors: (nenhum — crie wendkeep.sensors.json na raiz)\n'); process.exit(0); }
25
+ for (const s of sensors) process.stdout.write(`${s.id}: ${s.type || 'command'} · ${s.severity || 'critical'} · ${s.command}\n`);
26
+ process.exit(0);
15
27
  }
16
- const projectRoot = resolve(project || process.cwd());
17
- const sensors = loadSensors(projectRoot);
18
- if (!sensors.length) { process.stdout.write('sensors: (nenhum crie wendkeep.sensors.json na raiz)\n'); process.exit(0); }
19
- for (const s of sensors) {
20
- process.stdout.write(`${s.id}: ${s.type || 'command'} · ${s.severity || 'critical'} · ${s.command}\n`);
28
+
29
+ if (sub === 'add') {
30
+ const flags = new Set(['--severity', '--type', '--report', '--name', '--description', '--project']);
31
+ const positional = rest.filter((a, i) => !a.startsWith('--') && !flags.has(rest[i - 1]));
32
+ const [id, command] = positional;
33
+ if (!id || !command) { process.stderr.write('wendkeep sensors add: precisa <id> "<command>"\n'); process.exit(2); }
34
+ const projectRoot = resolveProject(rest);
35
+ const path = join(projectRoot, 'wendkeep.sensors.json');
36
+ let cfg = { $schema: SCHEMA, version: 1, source: 'manual', sensors: [] };
37
+ if (existsSync(path)) {
38
+ try { cfg = JSON.parse(readFileSync(path, 'utf8')); } catch { process.stderr.write('wendkeep sensors add: wendkeep.sensors.json inválido\n'); process.exit(2); }
39
+ if (!Array.isArray(cfg.sensors)) cfg.sensors = [];
40
+ if (!cfg.$schema) cfg.$schema = SCHEMA;
41
+ }
42
+ if (cfg.sensors.some((s) => s.id === id)) { process.stderr.write(`wendkeep sensors add: sensor "${id}" já existe\n`); process.exit(2); }
43
+ const severity = opt(rest, '--severity') === 'warning' ? 'warning' : 'critical';
44
+ const type = opt(rest, '--type') === 'mutation' ? 'mutation' : 'command';
45
+ const sensor = { id, name: opt(rest, '--name') || id, description: opt(rest, '--description') || command, severity, type, command };
46
+ if (type === 'mutation') sensor.report = opt(rest, '--report') || 'reports/mutation/mutation.json';
47
+ cfg.sensors.push(sensor);
48
+ writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
49
+ process.stdout.write(`sensor added: ${id} (${type} · ${severity})\n`);
50
+ process.exit(0);
21
51
  }
22
- process.exit(0);
52
+
53
+ process.stderr.write(`wendkeep sensors: unknown subcommand "${sub}". Known: list, add.\n`);
54
+ process.exit(2);
23
55
  }