wendkeep 0.21.0 → 0.23.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/CHANGELOG.md +63 -0
- package/README.md +7 -5
- package/bin/wendkeep.mjs +13 -2
- package/hooks/brain-core.mjs +21 -5
- package/hooks/change-core.mjs +27 -5
- package/hooks/import-sessions.mjs +31 -7
- package/hooks/linked-notes.mjs +29 -3
- package/hooks/obsidian-common.mjs +38 -7
- package/hooks/pricing.json +7 -0
- package/hooks/session-stop.mjs +15 -5
- package/hooks/token-usage.mjs +24 -2
- package/package.json +1 -1
- package/src/change.mjs +9 -0
- package/src/init.mjs +19 -3
- package/src/vault-views.mjs +99 -0
- package/src/verify.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ 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.23.0] — 2026-07-08
|
|
8
|
+
|
|
9
|
+
Vault structure — generated views + housekeeping (audit wave 2).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Generated Bases + Dashboard MOC**: `wendkeep init` now writes one folder-filtered `.base`
|
|
13
|
+
per taxonomy area (sessions/decisions/bugs/learnings/specs/changes) and a `00-Dashboard.md`
|
|
14
|
+
that embeds them — the vault's structural index. Filters are **by folder**
|
|
15
|
+
(`file.inFolder("05-Bugs")`), fixing the tag-filter that hid ~1/3 of bugs. New
|
|
16
|
+
`wendkeep dashboard [--force]` (re)generates them; non-destructive (never clobbers your own
|
|
17
|
+
bases). Locale-aware. `src/vault-views.mjs`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Archive ADRs land in the dated month folder** (`04-Decisões/<year>/<MM-MMM>/`) alongside
|
|
21
|
+
session-derived decisions, instead of the year root. `hooks/change-core.mjs`.
|
|
22
|
+
- **`SESSION_REGISTRY` is pruned** on the idle sweep: `done` entries older than 90 days, then a
|
|
23
|
+
cap of 200 most-recent — active entries are never touched. Bounds the per-hook read/serialize
|
|
24
|
+
cost that had grown to 330 entries / ~170 KB in production. `hooks/obsidian-common.mjs`.
|
|
25
|
+
- **Generated note names truncate on a word boundary** instead of mid-word (`slugify` gained a
|
|
26
|
+
boundary-aware `maxLen`). `hooks/obsidian-common.mjs`, `hooks/linked-notes.mjs`.
|
|
27
|
+
- **Learnings dedup vault-wide**: a learning already recorded anywhere in `06-Aprendizados`
|
|
28
|
+
(by `content_key`) is not re-emitted on a later day/session. `hooks/linked-notes.mjs`.
|
|
29
|
+
|
|
30
|
+
### Deferred
|
|
31
|
+
- Unifying the two `buildSessionContent` skeletons (session-start / session-ensure) stays as
|
|
32
|
+
tracked tech-debt — pure refactor, high regression risk in the capture layer, and the
|
|
33
|
+
user-facing drift (`session_id`) was already closed in 0.18/0.21.
|
|
34
|
+
|
|
35
|
+
## [0.22.0] — 2026-07-08
|
|
36
|
+
|
|
37
|
+
Hardening — 10 audit-confirmed bugs fixed (each survived an adversarial refuter).
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **Archive trusted stale evidence**: `verify` now seals `evidencia.json` with a `.evidence-hash`
|
|
41
|
+
(the `tarefas.md` hash it ran against); the archive gate rejects evidence gone stale (a sensor
|
|
42
|
+
task added/edited after the last green verify). `src/verify.mjs`, `src/change.mjs`.
|
|
43
|
+
- **Archiving a non-active change wiped the active pointer**: `archiveChange` now only clears
|
|
44
|
+
`CURRENT_CHANGE` when the archived slug IS the active one. `hooks/change-core.mjs`.
|
|
45
|
+
- **Non-atomic archive**: a destination-exists guard fails BEFORE promoting specs (same-day slug
|
|
46
|
+
reuse no longer half-promotes `07-Specs` then errors on the move); `renameSync` wrapped.
|
|
47
|
+
- **Archived proposta kept `status: active`** → flipped to `status: archived` on archive.
|
|
48
|
+
- **Import dropped Codex sessions whose `session_meta` exceeded 16KB** (~31% in production): the
|
|
49
|
+
reader now grows the buffer to the first newline instead of a fixed prefix. `hooks/import-sessions.mjs`.
|
|
50
|
+
- **Cost was silently $0 for untabled models**: `normalizeModelName` strips a `[1m]` context tag
|
|
51
|
+
generically (so `claude-opus-4-8[1m]` prices), `claude-sonnet-5` added, plus approximate Codex
|
|
52
|
+
`gpt-5.4`/`gpt-5.3-codex` aliases. `hooks/token-usage.mjs`, `hooks/pricing.json`.
|
|
53
|
+
- **Imported session titles came from harness meta-prompts** ("Generate a concise title…"): those
|
|
54
|
+
utility prompts are now filtered in both parsers' `shouldIgnoreUserText`.
|
|
55
|
+
- **Session↔change link died on reopen**: the change wikilink moved from an append after
|
|
56
|
+
`## Encerramento` (stripped every turn) to a durable `## Mudanças` section before it, which
|
|
57
|
+
accumulates every change the session touched. `hooks/session-stop.mjs`.
|
|
58
|
+
- **`init --force` duplicated every hook group**: now refreshes the managed entry in place instead
|
|
59
|
+
of appending a second identical group. `src/init.mjs`.
|
|
60
|
+
- **Injected DIGEST carried dead wikilinks**: `buildBrainDigest` now keeps only targets that
|
|
61
|
+
resolve to a real note and drops truncated placeholders. `hooks/brain-core.mjs`.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
- **Docs coherence**: `--help` moved `--top` from `import` to `cost`, gave `import` its real flags
|
|
65
|
+
(`--source`/`--stamp-ids`/`--from`/`--codex-from`/`--limit`/`--dry-run`, "Claude + Codex"), and
|
|
66
|
+
added `verify [--deep]`. README dropped the stale "v0.1" framing, fixed the 5→6 skill list
|
|
67
|
+
(adds `wk-verify`), made the `docs/` link absolute, and documented the `<wk_process>` router +
|
|
68
|
+
the G0 scaffold gate.
|
|
69
|
+
|
|
7
70
|
## [0.21.0] — 2026-07-08
|
|
8
71
|
|
|
9
72
|
Process enforcement — fixes from a real planning failure (production session): the model planned
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**A persistent‑memory harness for AI coding agents, built on your Obsidian vault.** Every Claude Code / Codex session is captured turn‑by‑turn into local Markdown — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. On top of that memory core sits a native, zero‑dependency **change lifecycle** (spec → change → TDD → sensor‑gated archive) that keeps intent, work and proof wikilinked in one graph. 100% local, open‑core.
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> Extracted from a system in daily production use: the capture engine, cost tracking and graph wiring are battle‑tested; the cross‑platform installer (`wendkeep init`) and the native change loop are the newer parts. See [`docs/`](https://github.com/rogersialves/wendkeep/tree/main/docs) for the project's strategy and decision log.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -49,7 +49,7 @@ npx wendkeep init
|
|
|
49
49
|
Control with `--companions <csv>` or `--no-companions`. The Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) is wired as a bonus where the companion has one.
|
|
50
50
|
5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
|
|
51
51
|
6. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
|
|
52
|
-
7. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning
|
|
52
|
+
7. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (some ship bundled templates, e.g. `wk-verify`'s `verdict-template.json` + reviewer prompt). `init` runs `wendkeep sync-defs` for you, delivering them to `.codex/agents/` + `.claude/skills/` (re‑run `sync-defs` after editing `.brain`).
|
|
53
53
|
8. Seed the **change/spec lifecycle**: the `07-Specs/` + `08-Mudanças/` folders and a native `wendkeep.sensors.json` (a `validate-memory` sensor plus one per detected `package.json` script). Drives `wendkeep change` / `wendkeep verify` — see **Change lifecycle** below.
|
|
54
54
|
|
|
55
55
|
```bash
|
|
@@ -170,14 +170,16 @@ agent session ──hooks──▶ wendkeep ──▶ Markdown in vault ──
|
|
|
170
170
|
(Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On
|
|
173
|
+
The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On every `SessionStart`, `brain-inject` injects back the curated memory (CORE + DIGEST), the active change, project lessons, and a `<wk_process>` router that routes any non‑trivial task through the a2 loop (plan → `change new` + fill the scaffold → TDD → verify → archive).
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
The archive **gate** blocks unless: the change scaffold is filled (G0), no task is open (G1), every declared critical sensor is green (with fresh evidence), and — when the change declares `[req:]` — an independent `verdict.json` covers them. `--force` is the human escape hatch; the agent is instructed never to use it on its own.
|
|
176
|
+
|
|
177
|
+
## Notes & roadmap
|
|
176
178
|
|
|
177
179
|
- **Vault folder names default to Portuguese** (`02-Sessões`, `04-Decisões`, …). Pass `wendkeep init --locale en` for an English vault (`02-Sessions`, `04-Decisions`, English scaffold/skills). The locale is a vault property, locked at init; parsers are bilingual so mixed content never breaks.
|
|
178
180
|
- **Search is keyword/frontmatter scoring**, not on‑device embeddings (that's on the roadmap).
|
|
179
181
|
- **Transcript formats are agent‑internal** and can change between agent versions; parsing is isolated but may need updates.
|
|
180
|
-
- Installer wires **Claude Code** settings + `.mcp.json`. Codex hooks run on the same scripts but are not auto‑wired yet.
|
|
182
|
+
- Installer wires **Claude Code** settings + `.mcp.json`. Codex hooks run on the same scripts but are not auto‑wired yet (import already covers past Codex sessions via `--source codex`).
|
|
181
183
|
|
|
182
184
|
## License
|
|
183
185
|
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -48,9 +48,15 @@ Usage:
|
|
|
48
48
|
wendkeep spec <sub> Living specs: list | show <capability>.
|
|
49
49
|
wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
|
|
50
50
|
wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
|
|
51
|
-
wendkeep import [opts] Backfill: import this project's past Claude sessions into the vault.
|
|
52
51
|
--since <date> · --top [N] (priciest sessions) · --json.
|
|
53
|
-
wendkeep
|
|
52
|
+
wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
|
|
53
|
+
the vault (deduped by session_id). --source all|claude|codex (default
|
|
54
|
+
all) · --stamp-ids (backfill session_id in existing notes) ·
|
|
55
|
+
--from <dir> · --codex-from <dir> · --since <date> · --limit N ·
|
|
56
|
+
--dry-run · --json.
|
|
57
|
+
wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
|
|
58
|
+
--deep assembles the verification package for the wk-verify pass.
|
|
59
|
+
wendkeep dashboard [--force] (Re)generate the vault's folder-filtered Bases + 00-Dashboard MOC.
|
|
54
60
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
55
61
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
56
62
|
protocol (cap 25, 3 sections, no secrets/PII). Uses
|
|
@@ -143,6 +149,11 @@ async function main() {
|
|
|
143
149
|
runImportCli(rest);
|
|
144
150
|
break;
|
|
145
151
|
}
|
|
152
|
+
case 'dashboard': {
|
|
153
|
+
const { runDashboard } = await import('../src/vault-views.mjs');
|
|
154
|
+
runDashboard(rest);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
146
157
|
case '--version':
|
|
147
158
|
case '-v':
|
|
148
159
|
process.stdout.write(`${version()}\n`);
|
package/hooks/brain-core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// .agent/hooks/brain-core.mjs
|
|
2
2
|
// Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
|
|
3
|
-
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
5
|
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
6
|
import { getLocale } from './locale.mjs';
|
|
7
7
|
|
|
@@ -123,10 +123,26 @@ export function buildBrainDigest(vaultBase, rows = null) {
|
|
|
123
123
|
return out;
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
|
|
127
|
+
// context. Keep only targets that resolve to a real note (by vault-relative path or basename)
|
|
128
|
+
// and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
|
|
129
|
+
// caps still fill after filtering.
|
|
130
|
+
const known = new Set();
|
|
131
|
+
for (const r of data) {
|
|
132
|
+
const rel = String(r.file || '').replace(/\.md$/i, '');
|
|
133
|
+
if (rel) { known.add(rel); known.add(basename(rel)); }
|
|
134
|
+
}
|
|
135
|
+
const resolves = (p) => {
|
|
136
|
+
const t = String(p || '').replace(/\.md$/i, '').trim();
|
|
137
|
+
if (!t || t.includes('...') || t.includes('…')) return false;
|
|
138
|
+
return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
|
|
139
|
+
};
|
|
140
|
+
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
|
+
|
|
142
|
+
const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
|
|
127
143
|
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
128
|
-
const bugs =
|
|
129
|
-
const learnings =
|
|
144
|
+
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
|
+
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
130
146
|
|
|
131
147
|
const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
|
|
132
148
|
for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
|
package/hooks/change-core.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
-
import { ensureDir, wikilinkFromRel } from './obsidian-common.mjs';
|
|
6
|
+
import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
|
|
7
7
|
import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
|
|
8
8
|
import { getLocale } from './locale.mjs';
|
|
9
9
|
|
|
@@ -222,8 +222,16 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
222
222
|
if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
|
|
223
223
|
|
|
224
224
|
const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}`);
|
|
225
|
+
const destAbs = join(vaultBase, destRel);
|
|
225
226
|
const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
|
|
226
227
|
|
|
228
|
+
// Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
|
|
229
|
+
// reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
|
|
230
|
+
// later renameSync would fail, leaving a half-archived state.
|
|
231
|
+
if (existsSync(destAbs)) {
|
|
232
|
+
return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
// Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
|
|
228
236
|
let promoted = [];
|
|
229
237
|
let specWarnings = [];
|
|
@@ -240,10 +248,22 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
240
248
|
try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
|
|
241
249
|
|
|
242
250
|
ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
|
|
243
|
-
|
|
251
|
+
try {
|
|
252
|
+
renameSync(src, destAbs);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return { ok: false, failing: [`falha ao mover a mudança para ${destRel}: ${error.message} (07-Specs pode ter sido promovido — verifique)`] };
|
|
255
|
+
}
|
|
244
256
|
|
|
245
|
-
|
|
246
|
-
|
|
257
|
+
// Flip the archived proposta's frontmatter status so it no longer reads as active.
|
|
258
|
+
try {
|
|
259
|
+
const pp = join(destAbs, 'proposta.md');
|
|
260
|
+
const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
|
|
261
|
+
writeFileSync(pp, c, 'utf8');
|
|
262
|
+
} catch { /* proposta ilegível — segue */ }
|
|
263
|
+
|
|
264
|
+
// ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
|
|
265
|
+
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
266
|
+
const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
|
|
247
267
|
ensureDir(join(vaultBase, adrDirRel));
|
|
248
268
|
const num = String(adrNum).padStart(3, '0');
|
|
249
269
|
const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
|
|
@@ -268,6 +288,8 @@ tags:
|
|
|
268
288
|
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
|
|
269
289
|
`, 'utf8');
|
|
270
290
|
|
|
271
|
-
|
|
291
|
+
// Only clear the pointer when the archived change IS the active one — archiving some other
|
|
292
|
+
// slug explicitly must not blank the pointer of a different, still-active change.
|
|
293
|
+
if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
|
|
272
294
|
return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
|
|
273
295
|
}
|
|
@@ -97,15 +97,39 @@ function readPrefix(path, bytes = 4096) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Read the first physical line in full, growing the buffer until a newline is found (capped).
|
|
101
|
+
// A fixed prefix truncated any rollout whose session_meta line exceeded the window, silently
|
|
102
|
+
// dropping that session from discovery — Codex meta lines can be large (env, git, instructions).
|
|
103
|
+
function readFirstLine(path, maxBytes = 4 * 1024 * 1024) {
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = openSync(path, 'r');
|
|
107
|
+
const chunk = Buffer.alloc(65536);
|
|
108
|
+
let acc = '';
|
|
109
|
+
let pos = 0;
|
|
110
|
+
while (pos < maxBytes) {
|
|
111
|
+
const n = readSync(fd, chunk, 0, chunk.length, pos);
|
|
112
|
+
if (n <= 0) break;
|
|
113
|
+
acc += chunk.slice(0, n).toString('utf-8');
|
|
114
|
+
const nl = acc.indexOf('\n');
|
|
115
|
+
if (nl >= 0) return acc.slice(0, nl);
|
|
116
|
+
pos += n;
|
|
117
|
+
}
|
|
118
|
+
return acc; // single-line file, or gave up at the cap
|
|
119
|
+
} catch {
|
|
120
|
+
return '';
|
|
121
|
+
} finally {
|
|
122
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
// Pull the session_meta payload (id + cwd). session_meta is line 1 of a rollout.
|
|
101
127
|
function readSessionMeta(path) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
return null;
|
|
128
|
+
const line = readFirstLine(path);
|
|
129
|
+
if (!line.trim()) return null;
|
|
130
|
+
let e;
|
|
131
|
+
try { e = JSON.parse(line); } catch { return null; }
|
|
132
|
+
return e.type === 'session_meta' ? (e.payload || {}) : null;
|
|
109
133
|
}
|
|
110
134
|
|
|
111
135
|
// The `session_id` recorded in a note's frontmatter (empty when absent).
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -477,6 +477,30 @@ function listMd(dir) {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
// Chaves content_key das derivadas já existentes que linkam esta sessão.
|
|
480
|
+
// Vault-wide learning content_keys (recursive over the learnings folder). existingKeysForSession
|
|
481
|
+
// only looks at the current session + month, so the same lesson re-extracted on a later day/
|
|
482
|
+
// session was duplicated. This dedups a learning against everything already learned in the vault.
|
|
483
|
+
function collectLearningKeys(vaultBase) {
|
|
484
|
+
const keys = new Set();
|
|
485
|
+
const root = join(vaultBase, getLocale(vaultBase).folders.learnings);
|
|
486
|
+
const walk = (d) => {
|
|
487
|
+
let entries;
|
|
488
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
489
|
+
for (const e of entries) {
|
|
490
|
+
const p = join(d, e.name);
|
|
491
|
+
if (e.isDirectory()) walk(p);
|
|
492
|
+
else if (e.name.endsWith('.md')) {
|
|
493
|
+
try {
|
|
494
|
+
const m = readFileSync(p, 'utf-8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
|
|
495
|
+
if (m && m[1]) keys.add(m[1]);
|
|
496
|
+
} catch { /* nota ilegível */ }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
walk(root);
|
|
501
|
+
return keys;
|
|
502
|
+
}
|
|
503
|
+
|
|
480
504
|
function existingKeysForSession(vaultBase, sessionRel, dateStr) {
|
|
481
505
|
const wikilink = wikilinkFromRel(sessionRel);
|
|
482
506
|
const out = { bugs: [], decisions: [], learnings: [] };
|
|
@@ -518,7 +542,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
|
|
|
518
542
|
const issueRef = issueRefs[0] || '';
|
|
519
543
|
const bugKey = derivedContentKey(bugDetails.rootCause);
|
|
520
544
|
if (!alreadyHasKey(existingKeys.bugs, bugKey)) {
|
|
521
|
-
const causeSlug = slugify(bugDetails.rootCause
|
|
545
|
+
const causeSlug = slugify(bugDetails.rootCause, 'bug', 40);
|
|
522
546
|
const fileName = issueRef ? `${issueRef}-${causeSlug}.md` : `${dateStr}-bug-${causeSlug}.md`;
|
|
523
547
|
const filePath = join(bugsDir, fileName);
|
|
524
548
|
if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey, loc.id), 'utf-8');
|
|
@@ -531,7 +555,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
|
|
|
531
555
|
if (decisionDetails) {
|
|
532
556
|
const decisionKey = derivedContentKey(decisionDetails.title);
|
|
533
557
|
if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
|
|
534
|
-
const titleSlug = slugify(decisionDetails.title
|
|
558
|
+
const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
|
|
535
559
|
const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
|
|
536
560
|
const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(3, '0')}-${titleSlug}.md`;
|
|
537
561
|
const filePath = join(decisionsDir, fileName);
|
|
@@ -546,10 +570,12 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
|
|
|
546
570
|
|
|
547
571
|
const learnings = extractLearningDetails(tx, bugDetails);
|
|
548
572
|
if (learnings) {
|
|
573
|
+
const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
|
|
549
574
|
for (const learning of learnings) {
|
|
550
575
|
const learningKey = derivedContentKey(learning.title);
|
|
551
576
|
if (alreadyHasKey(existingKeys.learnings, learningKey)) continue;
|
|
552
|
-
|
|
577
|
+
if (vaultLearningKeys.has(learningKey)) continue; // already learned elsewhere in the vault
|
|
578
|
+
const learningSlug = slugify(learning.title, 'aprendizado', 40);
|
|
553
579
|
const fileName = `${dateStr}-${learningSlug}.md`;
|
|
554
580
|
const filePath = join(learningsDir, fileName);
|
|
555
581
|
if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey, loc.id), 'utf-8');
|
|
@@ -289,12 +289,38 @@ export function sweepStaleSessions(registry, nowMs, maxIdleMs, excludeTranscript
|
|
|
289
289
|
return closed;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
//
|
|
292
|
+
// Registry retention. The registry is read/serialized in full on every hook and scanned O(N)
|
|
293
|
+
// for routing — it only needs active + recent sessions (historical audit lives in the notes).
|
|
294
|
+
// Left unbounded it grew to 330 entries / ~170 KB in production.
|
|
295
|
+
export const REGISTRY_KEEP_DONE = 200;
|
|
296
|
+
export const REGISTRY_DONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
297
|
+
|
|
298
|
+
// Pure: drop 'done' entries older than maxAgeMs, then cap the remaining 'done' at keepDone
|
|
299
|
+
// (newest by ended_at/updated_at/started_at kept). Never touches active entries. Mutates the
|
|
300
|
+
// registry and returns how many were pruned.
|
|
301
|
+
export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE, maxAgeMs = REGISTRY_DONE_MAX_AGE_MS } = {}) {
|
|
302
|
+
const sessions = registry?.sessions || {};
|
|
303
|
+
const stamp = (v) => Date.parse((v && (v.ended_at || v.updated_at || v.started_at)) || '') || 0;
|
|
304
|
+
let pruned = 0;
|
|
305
|
+
for (const [id, v] of Object.entries(sessions)) {
|
|
306
|
+
if (!v || v.status !== 'done') continue;
|
|
307
|
+
const t = stamp(v);
|
|
308
|
+
if (t && nowMs - t > maxAgeMs) { delete sessions[id]; pruned += 1; }
|
|
309
|
+
}
|
|
310
|
+
const done = Object.entries(sessions)
|
|
311
|
+
.filter(([, v]) => v && v.status === 'done')
|
|
312
|
+
.sort((a, b) => stamp(b[1]) - stamp(a[1]));
|
|
313
|
+
for (const [id] of done.slice(keepDone)) { delete sessions[id]; pruned += 1; }
|
|
314
|
+
return pruned;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
|
|
293
318
|
// sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
|
|
294
319
|
export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
|
|
295
320
|
const registry = readSessionRegistry(vaultBase);
|
|
296
321
|
const closed = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
|
|
297
|
-
|
|
322
|
+
const pruned = pruneRegistry(registry, now.getTime());
|
|
323
|
+
if (closed.length || pruned) writeSessionRegistry(vaultBase, registry);
|
|
298
324
|
for (const { session_file, ended_at } of closed) {
|
|
299
325
|
try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
|
|
300
326
|
}
|
|
@@ -335,15 +361,20 @@ export function closeSessionNote(content, endedAt) {
|
|
|
335
361
|
return next;
|
|
336
362
|
}
|
|
337
363
|
|
|
338
|
-
export function slugify(text, fallback = 'nota') {
|
|
339
|
-
|
|
364
|
+
export function slugify(text, fallback = 'nota', maxLen = 60) {
|
|
365
|
+
let slug = String(text || '')
|
|
340
366
|
.normalize('NFD')
|
|
341
367
|
.replace(/[\u0300-\u036f]/g, '')
|
|
342
368
|
.toLowerCase()
|
|
343
369
|
.replace(/[^a-z0-9]+/g, '-')
|
|
344
|
-
.replace(/^-+|-+$/g, '')
|
|
345
|
-
|
|
346
|
-
|
|
370
|
+
.replace(/^-+|-+$/g, '');
|
|
371
|
+
if (slug.length > maxLen) {
|
|
372
|
+
// Truncate on a word boundary (last '-' before maxLen) when a reasonable one exists,
|
|
373
|
+
// instead of cutting mid-word \u2014 keeps generated note names readable.
|
|
374
|
+
const cut = slug.slice(0, maxLen);
|
|
375
|
+
const lastDash = cut.lastIndexOf('-');
|
|
376
|
+
slug = (lastDash > maxLen * 0.5 ? cut.slice(0, lastDash) : cut).replace(/-+$/g, '');
|
|
377
|
+
}
|
|
347
378
|
return slug || fallback;
|
|
348
379
|
}
|
|
349
380
|
|
package/hooks/pricing.json
CHANGED
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
"cachedInput": 0.3,
|
|
31
31
|
"output": 15
|
|
32
32
|
},
|
|
33
|
+
"claude-sonnet-5": {
|
|
34
|
+
"label": "Claude Sonnet 5 API",
|
|
35
|
+
"provider": "anthropic",
|
|
36
|
+
"input": 3,
|
|
37
|
+
"cachedInput": 0.3,
|
|
38
|
+
"output": 15
|
|
39
|
+
},
|
|
33
40
|
"claude-haiku-4.5": {
|
|
34
41
|
"label": "Claude Haiku 4.5 API",
|
|
35
42
|
"provider": "anthropic",
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -53,7 +53,11 @@ function shouldIgnoreUserText(text) {
|
|
|
53
53
|
|| /^# AGENTS\.md instructions/.test(trimmed)
|
|
54
54
|
|| trimmed.startsWith('<permissions instructions>')
|
|
55
55
|
|| trimmed.includes('You are Codex, a coding agent')
|
|
56
|
-
|| trimmed.startsWith('## Memory')
|
|
56
|
+
|| trimmed.startsWith('## Memory')
|
|
57
|
+
// Harness utility meta-prompts (title generation, classifiers) — not real user turns; they
|
|
58
|
+
// were leaking into note titles/summaries on import.
|
|
59
|
+
|| /^Generate a concise( UI)? title/i.test(trimmed)
|
|
60
|
+
|| /^You are a helpful assistant\. You will be presented with a user prompt/i.test(trimmed);
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
function addUnique(list, value) {
|
|
@@ -1086,12 +1090,18 @@ function main() {
|
|
|
1086
1090
|
findLinkedDerivedNotes(vaultBase, sessionRel),
|
|
1087
1091
|
);
|
|
1088
1092
|
finalizeSessionFile(sessionPath, tx, created, endedAt);
|
|
1089
|
-
// Link
|
|
1093
|
+
// Link durável sessão↔change: uma seção "Mudanças" ANTES de `## Encerramento`. O append antigo
|
|
1094
|
+
// (após o Encerramento) era apagado a cada reopen por stripClosingSection, perdendo a aresta do
|
|
1095
|
+
// grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
|
|
1096
|
+
// change que passou pela sessão (upsertListSection deduplica). Fail-quiet: nunca derruba o Stop.
|
|
1090
1097
|
try {
|
|
1091
1098
|
const chgLink = activeChangeLink(vaultBase);
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1099
|
+
const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
|
|
1100
|
+
if (wl) {
|
|
1101
|
+
let cur = readFileSync(sessionPath, 'utf8');
|
|
1102
|
+
cur = ensureSection(cur, 'Mudanças', '\n## Encerramento');
|
|
1103
|
+
cur = upsertListSection(cur, 'Mudanças', [`- ${wl}`], null);
|
|
1104
|
+
writeFileSync(sessionPath, cur, 'utf8');
|
|
1095
1105
|
}
|
|
1096
1106
|
} catch { /* nunca derruba o Stop */ }
|
|
1097
1107
|
writeControl(vaultBase, {
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -41,6 +41,13 @@ const DEFAULT_PRICE_REFERENCE = {
|
|
|
41
41
|
cachedInput: 0.3,
|
|
42
42
|
output: 15,
|
|
43
43
|
},
|
|
44
|
+
'claude-sonnet-5': {
|
|
45
|
+
label: 'Claude Sonnet 5 API',
|
|
46
|
+
provider: 'anthropic',
|
|
47
|
+
input: 3,
|
|
48
|
+
cachedInput: 0.3,
|
|
49
|
+
output: 15,
|
|
50
|
+
},
|
|
44
51
|
'claude-haiku-4.5': {
|
|
45
52
|
label: 'Claude Haiku 4.5 API',
|
|
46
53
|
provider: 'anthropic',
|
|
@@ -79,6 +86,14 @@ const MODEL_ALIASES = {
|
|
|
79
86
|
'gpt-5.5': 'gpt-5.5',
|
|
80
87
|
'gpt-5_5': 'gpt-5.5',
|
|
81
88
|
'openai/gpt-5.5': 'gpt-5.5',
|
|
89
|
+
// Older/adjacent Codex model ids: priced approximately at the gpt-5.5 tier until confirmed
|
|
90
|
+
// (better a close estimate than a silent $0). Codex sessions surface these via session_meta.
|
|
91
|
+
'gpt-5.4': 'gpt-5.5',
|
|
92
|
+
'gpt-5-4': 'gpt-5.5',
|
|
93
|
+
'gpt-5.4-mini': 'gpt-5.5',
|
|
94
|
+
'gpt-5.3-codex': 'gpt-5.5',
|
|
95
|
+
'gpt-5.3': 'gpt-5.5',
|
|
96
|
+
'openai/gpt-5.4': 'gpt-5.5',
|
|
82
97
|
'claude-opus-4.7': 'claude-opus-4.7',
|
|
83
98
|
'claude-opus-4-7': 'claude-opus-4.7',
|
|
84
99
|
'anthropic/claude-opus-4.7': 'claude-opus-4.7',
|
|
@@ -91,6 +106,9 @@ const MODEL_ALIASES = {
|
|
|
91
106
|
'claude-sonnet-4-6': 'claude-sonnet-4.6',
|
|
92
107
|
'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
|
|
93
108
|
'anthropic/claude-sonnet-4-6': 'claude-sonnet-4.6',
|
|
109
|
+
'claude-sonnet-5': 'claude-sonnet-5',
|
|
110
|
+
'claude-sonnet-5-0': 'claude-sonnet-5',
|
|
111
|
+
'anthropic/claude-sonnet-5': 'claude-sonnet-5',
|
|
94
112
|
'claude-haiku-4.5': 'claude-haiku-4.5',
|
|
95
113
|
'claude-haiku-4-5': 'claude-haiku-4.5',
|
|
96
114
|
'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
|
|
@@ -191,7 +209,9 @@ export function addUsage(target, usage) {
|
|
|
191
209
|
|
|
192
210
|
function normalizeModelName(model) {
|
|
193
211
|
const clean = String(model || 'unknown').trim() || 'unknown';
|
|
194
|
-
|
|
212
|
+
// Strip a trailing context-window tag (e.g. `claude-opus-4-8[1m]`, `claude-fable-5[1m]`) so the
|
|
213
|
+
// 1M variant of ANY model maps to its base price instead of falling through to $0.
|
|
214
|
+
const lower = clean.toLowerCase().replace(/\[[^\]]*\]$/, '');
|
|
195
215
|
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
|
|
196
216
|
// Fallback: remove sufixo de data (ex.: claude-opus-4-8-20260528) e tenta de novo.
|
|
197
217
|
const noDate = lower.replace(/-\d{8}$/, '');
|
|
@@ -280,7 +300,9 @@ function shouldIgnoreUserText(text) {
|
|
|
280
300
|
|| text.startsWith('<command-name>')
|
|
281
301
|
|| text.startsWith('<ide_')
|
|
282
302
|
|| text.startsWith('## Memory')
|
|
283
|
-
|| text.includes('You are Codex, a coding agent')
|
|
303
|
+
|| text.includes('You are Codex, a coding agent')
|
|
304
|
+
|| /^Generate a concise( UI)? title/i.test(text)
|
|
305
|
+
|| /^You are a helpful assistant\. You will be presented with a user prompt/i.test(text);
|
|
284
306
|
}
|
|
285
307
|
|
|
286
308
|
function emptyParseResult(transcriptPath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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": {
|
package/src/change.mjs
CHANGED
|
@@ -171,6 +171,15 @@ export function runChange(argv) {
|
|
|
171
171
|
return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
|
|
172
172
|
}
|
|
173
173
|
const required = requiredSensors(tasks);
|
|
174
|
+
// Evidence freshness: block if tarefas.md changed since verify sealed the evidence
|
|
175
|
+
// (e.g. a sensor task added/edited after the last green verify).
|
|
176
|
+
if (required.length) {
|
|
177
|
+
let evHash = '';
|
|
178
|
+
try { evHash = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim(); } catch { /* pre-seal evidence */ }
|
|
179
|
+
if (evHash && evHash !== tasksHashOf(tarefasMd)) {
|
|
180
|
+
return { ok: false, failing: ['evidência stale (tarefas.md mudou desde o último verify) — rode `wendkeep verify` de novo'] };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
174
183
|
const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
|
|
175
184
|
let evidence = [];
|
|
176
185
|
try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
|
package/src/init.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
DOTCONTEXT_GITIGNORE,
|
|
23
23
|
} from './taxonomy.mjs';
|
|
24
24
|
import { renderVaultReadme } from './vault-readme.mjs';
|
|
25
|
+
import { seedVaultViews } from './vault-views.mjs';
|
|
25
26
|
import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
|
|
26
27
|
import {
|
|
27
28
|
SNIPPET_NAME,
|
|
@@ -104,8 +105,17 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
104
105
|
for (const h of allSpecs) {
|
|
105
106
|
const command = h.command ?? hookCommand(h.name);
|
|
106
107
|
const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
108
|
+
const owning = groups.find((g) => (g.hooks || []).some((x) => x.command === command));
|
|
109
|
+
if (owning) {
|
|
110
|
+
// Already wired: never add a duplicate group (the old `if (present && !force)` fell through
|
|
111
|
+
// under --force and appended a second identical group). Under --force, refresh the managed
|
|
112
|
+
// entry's fields in place — without disturbing any sibling hooks the user grouped with it.
|
|
113
|
+
if (force) {
|
|
114
|
+
const hk = owning.hooks.find((x) => x.command === command);
|
|
115
|
+
hk.timeout = h.timeout;
|
|
116
|
+
if (h.statusMessage) hk.statusMessage = h.statusMessage;
|
|
117
|
+
if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
|
|
118
|
+
}
|
|
109
119
|
s.hooks[h.event] = groups;
|
|
110
120
|
continue;
|
|
111
121
|
}
|
|
@@ -355,7 +365,13 @@ export async function runInit(argv) {
|
|
|
355
365
|
try { scripts = JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
|
|
356
366
|
writeFileSync(sensorsFile, renderSensorsJson(scripts), 'utf8');
|
|
357
367
|
}
|
|
358
|
-
|
|
368
|
+
// Generated Bases + Dashboard MOC (folder-filtered views over the taxonomy) — non-destructive.
|
|
369
|
+
let viewsNote = '';
|
|
370
|
+
try {
|
|
371
|
+
const views = seedVaultViews(vaultPath);
|
|
372
|
+
if (views.length) viewsNote = `, ${views.length} view(s) + dashboard`;
|
|
373
|
+
} catch { /* views são bônus — nunca derrubam o init */ }
|
|
374
|
+
log(` [1/4] vault taxonomy: ${folders.length} folders (${created} created, locale ${loc.id})${readmeNote}, .brain + change/spec + sensors seeded${viewsNote}`);
|
|
359
375
|
// Deliver the seeded defs (agents + wk process skills) to the project so they're
|
|
360
376
|
// usable immediately — no separate `wendkeep sync-defs` step needed.
|
|
361
377
|
const synced = syncDefs(vaultPath, projectPath);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Generated Obsidian Bases + a Dashboard MOC — the vault's structural index (0.23.0).
|
|
2
|
+
// Each taxonomy folder gets a folder-filtered `.base` (NOT tag-filtered: a tag filter hid ~1/3
|
|
3
|
+
// of the corpus in production) and 00-Dashboard.md embeds them. Locale-aware; non-destructive
|
|
4
|
+
// (never overwrites a user's own base) unless force is passed. Zero runtime deps.
|
|
5
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
import { getLocale } from '../hooks/locale.mjs';
|
|
8
|
+
|
|
9
|
+
// Per-area view: which folder, which frontmatter columns, and their display labels.
|
|
10
|
+
function areaSpecs(loc) {
|
|
11
|
+
const f = loc.folders;
|
|
12
|
+
const en = loc.id === 'en';
|
|
13
|
+
const date = en ? 'Date' : 'Data';
|
|
14
|
+
return [
|
|
15
|
+
{ key: 'sessoes', folder: f.sessions, title: en ? 'Sessions' : 'Sessões',
|
|
16
|
+
cols: ['date', 'provider', 'custo_modelo_usd', 'prompts', 'tool_calls'],
|
|
17
|
+
labels: { date, provider: en ? 'Agent' : 'Agente', custo_modelo_usd: 'US$', prompts: 'Prompts', tool_calls: 'Tools' } },
|
|
18
|
+
{ key: 'decisoes', folder: f.decisions, title: en ? 'Decisions' : 'Decisões',
|
|
19
|
+
cols: ['date', 'status'], labels: { date, status: 'Status' } },
|
|
20
|
+
{ key: 'bugs', folder: f.bugs, title: 'Bugs',
|
|
21
|
+
cols: ['date', 'status', 'severity'], labels: { date, status: 'Status', severity: en ? 'Severity' : 'Severidade' } },
|
|
22
|
+
{ key: 'aprendizados', folder: f.learnings, title: en ? 'Learnings' : 'Aprendizados',
|
|
23
|
+
cols: ['date'], labels: { date } },
|
|
24
|
+
{ key: 'specs', folder: f.specs, title: 'Specs', cols: ['date'], labels: { date } },
|
|
25
|
+
{ key: 'mudancas', folder: f.changes, title: en ? 'Changes' : 'Mudanças',
|
|
26
|
+
cols: ['date', 'status'], labels: { date, status: 'Status' } },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A minimal, valid Base: folder filter + one table view. No formulas (that's where Base syntax
|
|
31
|
+
// errors hide) — the folder filter is the whole fix.
|
|
32
|
+
export function renderBase(spec) {
|
|
33
|
+
const props = spec.cols.map((c) => ` ${c}:\n displayName: ${spec.labels[c] || c}`).join('\n');
|
|
34
|
+
const order = ['file.name', ...spec.cols].map((c) => ` - ${c}`).join('\n');
|
|
35
|
+
return `filters:
|
|
36
|
+
and:
|
|
37
|
+
- file.inFolder("${spec.folder}")
|
|
38
|
+
- file.ext == "md"
|
|
39
|
+
properties:
|
|
40
|
+
${props}
|
|
41
|
+
views:
|
|
42
|
+
- type: table
|
|
43
|
+
name: ${spec.title}
|
|
44
|
+
order:
|
|
45
|
+
${order}
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderDashboard(specs, en) {
|
|
50
|
+
const blocks = specs.map((s) => `## ${s.title}\n\n![[${s.key}.base]]`).join('\n\n---\n\n');
|
|
51
|
+
return `---
|
|
52
|
+
tags:
|
|
53
|
+
- dashboard
|
|
54
|
+
cssclasses:
|
|
55
|
+
- topic-dashboard
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# ${en ? 'Vault — Overview' : 'Vault — Visão Geral'}
|
|
59
|
+
|
|
60
|
+
> ${en ? 'Generated by wendkeep. Each section embeds a folder-filtered Base view (needs the Obsidian Bases core plugin).' : 'Gerado pelo wendkeep. Cada seção embeda uma view Base filtrada por pasta (requer o core plugin Bases do Obsidian).'}
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
${blocks}
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Write one `<key>.base` per area + 00-Dashboard.md. Non-destructive unless force. Returns the
|
|
69
|
+
// list of files written.
|
|
70
|
+
export function seedVaultViews(vaultBase, { force = false } = {}) {
|
|
71
|
+
const loc = getLocale(vaultBase);
|
|
72
|
+
const specs = areaSpecs(loc);
|
|
73
|
+
const written = [];
|
|
74
|
+
for (const s of specs) {
|
|
75
|
+
const p = join(vaultBase, `${s.key}.base`);
|
|
76
|
+
if (force || !existsSync(p)) { writeFileSync(p, renderBase(s), 'utf8'); written.push(`${s.key}.base`); }
|
|
77
|
+
}
|
|
78
|
+
const dash = join(vaultBase, '00-Dashboard.md');
|
|
79
|
+
if (force || !existsSync(dash)) { writeFileSync(dash, renderDashboard(specs, loc.id === 'en'), 'utf8'); written.push('00-Dashboard.md'); }
|
|
80
|
+
return written;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function opt(argv, name) {
|
|
84
|
+
const i = argv.indexOf(name);
|
|
85
|
+
if (i >= 0) return argv[i + 1];
|
|
86
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`));
|
|
87
|
+
return eq ? eq.slice(name.length + 1) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `wendkeep dashboard [--vault P] [--force]` — (re)generate the Bases + Dashboard.
|
|
91
|
+
export function runDashboard(argv) {
|
|
92
|
+
const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
|
|
93
|
+
if (!vaultRaw) { process.stderr.write('wendkeep dashboard: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
|
|
94
|
+
const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
|
|
95
|
+
if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep dashboard: vault not found: ${vaultBase}\n`); process.exit(2); }
|
|
96
|
+
const written = seedVaultViews(vaultBase, { force: argv.includes('--force') });
|
|
97
|
+
process.stdout.write(written.length ? `dashboard: ${written.length} arquivo(s) — ${written.join(', ')}\n` : 'dashboard: nada a fazer (use --force para regenerar)\n');
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
package/src/verify.mjs
CHANGED
|
@@ -38,6 +38,9 @@ export function runVerify(argv) {
|
|
|
38
38
|
const sensors = loadSensors(projectRoot);
|
|
39
39
|
const evidence = runSensors(sensors, ids, { cwd: projectRoot });
|
|
40
40
|
writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
41
|
+
// Freshness seal: bind this evidence to the tarefas.md it was produced against, so the archive
|
|
42
|
+
// gate can reject evidence gone stale (a sensor task added after this verify run).
|
|
43
|
+
writeFileSync(join(changeDir, '.evidence-hash'), tasksHashOf(tarefas), 'utf8');
|
|
41
44
|
|
|
42
45
|
// Mutation survivors -> fix tasks (Wave B), bounded at 3 rounds then escalate. A surviving
|
|
43
46
|
// mutant always fails verify (exit 1): the suite does not discriminate yet. A clean report
|