wendkeep 0.85.1 → 0.87.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 (49) hide show
  1. package/.githooks/commit-msg +16 -0
  2. package/.githooks/prepare-commit-msg +16 -0
  3. package/CHANGELOG.md +40 -0
  4. package/README.en.md +4 -1
  5. package/README.md +4 -1
  6. package/docs/en/commands/commit.md +159 -0
  7. package/docs/en/commands/evidence-embeddings.md +243 -0
  8. package/docs/en/commands/mcp.md +67 -7
  9. package/docs/pt-BR/commands/commit.md +159 -0
  10. package/docs/pt-BR/commands/evidence-embeddings.md +244 -0
  11. package/docs/pt-BR/commands/mcp.md +66 -7
  12. package/hooks/evidence-context.mjs +41 -7
  13. package/hooks/evidence-recall.mjs +10 -0
  14. package/package.json +5 -2
  15. package/packages/cli/src/index.mjs +11 -1
  16. package/packages/commit/package.json +6 -0
  17. package/packages/commit/src/cli.mjs +89 -0
  18. package/packages/commit/src/commit-input.mjs +181 -0
  19. package/packages/commit/src/commit-message.mjs +51 -0
  20. package/packages/commit/src/commit-policy.mjs +144 -0
  21. package/packages/commit/src/git-runtime.mjs +428 -0
  22. package/packages/commit/src/index.mjs +28 -0
  23. package/packages/commit/src/proof-validation.mjs +443 -0
  24. package/packages/mcp/src/effects.mjs +3 -2
  25. package/packages/mcp/src/evidence-recall.mjs +130 -0
  26. package/packages/mcp/src/executor.mjs +4 -0
  27. package/packages/mcp/src/server.mjs +31 -1
  28. package/packages/vault/src/evidence-embedding-plugin.mjs +531 -0
  29. package/packages/vault/src/evidence-index-store.mjs +360 -0
  30. package/packages/vault/src/evidence-recall-page.mjs +381 -0
  31. package/packages/vault/src/evidence-search-index.mjs +917 -0
  32. package/packages/vault/src/index.mjs +12 -1
  33. package/packages/vault/src/memory-ledger-view-base.mjs +545 -0
  34. package/packages/vault/src/memory-ledger-view.mjs +41 -0
  35. package/packages/vault/src/memory-rotation-store.mjs +967 -0
  36. package/packages/vault/src/memory-segment-store.mjs +820 -0
  37. package/packages/vault/src/memory-snapshot-store.mjs +1105 -0
  38. package/packages/vault/src/memory-store-base.mjs +1161 -0
  39. package/packages/vault/src/memory-store-core.mjs +2 -0
  40. package/packages/vault/src/memory-store.mjs +46 -1161
  41. package/schema/commit-message-v1.schema.json +75 -0
  42. package/scripts/validate-commit-range.mjs +244 -0
  43. package/src/doctor.mjs +48 -5
  44. package/src/evidence-search-health.mjs +221 -0
  45. package/src/git-commit-hooks.mjs +112 -0
  46. package/src/init.mjs +13 -0
  47. package/src/memory-scale-health.mjs +210 -0
  48. package/src/observer-snapshot.mjs +87 -1
  49. package/src/skills-seed.mjs +79 -0
@@ -0,0 +1,75 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://wendkeep.dev/schema/commit-message-v1.schema.json",
4
+ "title": "WendKeep evidence-based commit input",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "subject", "capability", "authority", "evidence"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "subject": {
11
+ "type": "object",
12
+ "additionalProperties": false,
13
+ "required": ["type", "summary"],
14
+ "properties": {
15
+ "type": { "enum": ["feat", "fix", "refactor", "perf"] },
16
+ "scope": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$" },
17
+ "summary": { "type": "string", "minLength": 1, "maxLength": 120 }
18
+ }
19
+ },
20
+ "capability": { "type": "string", "minLength": 1, "maxLength": 500 },
21
+ "authority": {
22
+ "oneOf": [
23
+ {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["kind", "adr", "ref"],
27
+ "properties": {
28
+ "kind": { "const": "adr" },
29
+ "adr": { "type": "string", "pattern": "^ADR-[0-9]{4,}$" },
30
+ "ref": { "type": "string", "minLength": 1 },
31
+ "issue": { "type": "string", "pattern": "^#[0-9]+$" }
32
+ }
33
+ },
34
+ {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["kind", "issue", "design"],
38
+ "properties": {
39
+ "kind": { "const": "native" },
40
+ "issue": { "type": "string", "pattern": "^#[0-9]+$" },
41
+ "design": { "type": "string", "pattern": "^(docs/superpowers/specs|plans)/(?!\\.{1,2}(?:/|$))(?!.*//)(?!.*\\/\\.{1,2}(?:\\/|$))[a-zA-Z0-9._/-]+\\.md$" }
42
+ }
43
+ }
44
+ ]
45
+ },
46
+ "staged_diff": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "required": ["sha256", "files"],
50
+ "properties": {
51
+ "sha256": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" },
52
+ "files": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
53
+ }
54
+ },
55
+ "evidence": {
56
+ "type": "array",
57
+ "minItems": 1,
58
+ "items": {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "required": ["kind", "ref"],
62
+ "properties": {
63
+ "kind": { "enum": ["adr", "design", "evidence", "receipt", "spec", "task", "verdict"] },
64
+ "ref": { "type": "string", "minLength": 1 }
65
+ }
66
+ }
67
+ },
68
+ "limits": { "type": "array", "items": { "type": "string", "minLength": 1 } },
69
+ "identity": {
70
+ "type": "object",
71
+ "additionalProperties": false,
72
+ "properties": { "agent": { "type": "string" } }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+
8
+ import {
9
+ messageEvidence, messageScope, messageTasks, messageTests, nativeDesignReference, validateCommitMessage,
10
+ } from '../packages/commit/src/index.mjs';
11
+ import {
12
+ collectCommitSensorProof,
13
+ commitTaskSensorIds,
14
+ parseSignedEvidenceRef,
15
+ validateCommitProofSet,
16
+ } from '../packages/commit/src/proof-validation.mjs';
17
+
18
+ function option(argv, name) {
19
+ const index = argv.indexOf(name);
20
+ if (index >= 0) return argv[index + 1] || '';
21
+ return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
22
+ }
23
+
24
+ function git(args, { binary = false, allowFailure = false } = {}) {
25
+ const result = spawnSync('git', args, {
26
+ encoding: binary ? null : 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024,
27
+ });
28
+ if (!allowFailure && result.status !== 0) {
29
+ process.stderr.write((binary ? result.stderr?.toString('utf8') : result.stderr) || `git ${args.join(' ')} failed\n`);
30
+ process.exit(2);
31
+ }
32
+ return result;
33
+ }
34
+ const text = (args, options) => String(git(args, options).stdout || '');
35
+ const objectExists = (specifier) => git(['cat-file', '-e', specifier], { allowFailure: true }).status === 0;
36
+ const parents = (sha) => text(['show', '-s', '--format=%P', sha]).trim().split(/\s+/).filter(Boolean);
37
+
38
+ function commitDiff(sha) {
39
+ const parent = parents(sha)[0];
40
+ const args = parent
41
+ ? ['diff', '--binary', '--no-ext-diff', '--no-color', parent, sha]
42
+ : ['show', '--format=', '--binary', '--no-ext-diff', '--no-color', sha];
43
+ return git(args, { binary: true }).stdout;
44
+ }
45
+
46
+ function changedFiles(sha) {
47
+ const parent = parents(sha)[0];
48
+ const args = parent
49
+ ? ['diff', '--name-only', '-z', '--no-renames', parent, sha]
50
+ : ['diff-tree', '--root', '--no-commit-id', '--name-only', '-z', '-r', sha];
51
+ return text(args).split('\0').filter(Boolean).map((path) => path.replaceAll('\\', '/'))
52
+ .sort((left, right) => left.localeCompare(right, 'en'));
53
+ }
54
+
55
+ function scopeFor(sha) {
56
+ return { sha256: createHash('sha256').update(commitDiff(sha)).digest('hex'), files: changedFiles(sha) };
57
+ }
58
+
59
+ const docsPath = (path) => /^(?:docs\/|README(?:\.en)?\.md$|[^/]+\.md$)/.test(path);
60
+ const testPath = (path) => /^(?:tests?\/|fixtures?\/)/.test(path) || /(?:^|\/)__tests__\//.test(path);
61
+ function trivialCommit(subject, files) {
62
+ if (!files.length) return false;
63
+ if (/^docs(?:\([^)]*\))?:/.test(subject)) return files.every(docsPath);
64
+ if (/^test(?:\([^)]*\))?:/.test(subject)) return files.every(testPath);
65
+ if (/^chore(?:\([^)]*\))?:/.test(subject)) return files.every((path) => docsPath(path) || testPath(path));
66
+ return false;
67
+ }
68
+
69
+ function mergeErrors(sha, message) {
70
+ const commitParents = parents(sha);
71
+ if (commitParents.length < 2 || !/^Merge\b/.test(message.split(/\r?\n/, 1)[0])) {
72
+ return ['merge commit must have multiple parents and a canonical Merge subject'];
73
+ }
74
+ const errors = [];
75
+ for (const path of changedFiles(sha)) {
76
+ const mergeObject = text(['rev-parse', `${sha}:${path}`], { allowFailure: true }).trim();
77
+ if (!mergeObject) continue;
78
+ const inherited = commitParents.some((parent) => (
79
+ text(['rev-parse', `${parent}:${path}`], { allowFailure: true }).trim() === mergeObject
80
+ ));
81
+ if (!inherited) errors.push(`WENDKEEP_COMMIT_MERGE_RESOLUTION_UNGOVERNED: ${path}`);
82
+ }
83
+ return errors;
84
+ }
85
+
86
+ function commitJson(sha, path) {
87
+ try { return JSON.parse(text(['show', `${sha}:${path}`])); } catch { return null; }
88
+ }
89
+
90
+ function collectSensorsAtCommit(sha, entries) {
91
+ const ids = commitTaskSensorIds(entries);
92
+ if (!ids.length) return null;
93
+ const config = commitJson(sha, 'wendkeep.sensors.json');
94
+ if (!config || !Array.isArray(config.sensors)) {
95
+ throw Object.assign(new Error('versioned wendkeep.sensors.json is required'), {
96
+ code: 'WENDKEEP_COMMIT_SENSOR_CONFIG_MISSING',
97
+ });
98
+ }
99
+ const parent = mkdtempSync(join(tmpdir(), 'wendkeep-commit-range-'));
100
+ const checkout = join(parent, 'checkout');
101
+ const added = git(['worktree', 'add', '--detach', '--force', checkout, sha], { allowFailure: true });
102
+ if (added.status !== 0) {
103
+ rmSync(parent, { recursive: true, force: true });
104
+ throw Object.assign(new Error(String(added.stderr || 'temporary commit checkout failed').trim()), {
105
+ code: 'WENDKEEP_COMMIT_CHECKOUT_FAILED',
106
+ });
107
+ }
108
+ try {
109
+ return collectCommitSensorProof({ sensors: config.sensors, ids, cwd: checkout });
110
+ } finally {
111
+ git(['worktree', 'remove', '--force', checkout], { allowFailure: true });
112
+ const bounded = resolve(parent);
113
+ if (bounded.startsWith(resolve(tmpdir()))) rmSync(bounded, { recursive: true, force: true });
114
+ }
115
+ }
116
+
117
+ function evidenceErrors(sha, message, scope) {
118
+ const errors = [];
119
+ const evidence = messageEvidence(message);
120
+ const entries = [];
121
+ for (const item of evidence) {
122
+ if (item.status !== 'verified' || ['evidence', 'receipt', 'verdict'].includes(item.kind)) {
123
+ errors.push('WENDKEEP_COMMIT_REMOTE_PROOF_UNAVAILABLE');
124
+ continue;
125
+ }
126
+ let signed;
127
+ try { signed = parseSignedEvidenceRef(item.ref); }
128
+ catch (error) { errors.push(error.code || error.message); continue; }
129
+ if (!objectExists(`${sha}:${signed.path}`)) {
130
+ errors.push(`WENDKEEP_COMMIT_EVIDENCE_UNVERSIONED: ${signed.path}`);
131
+ continue;
132
+ }
133
+ entries.push({ kind: item.kind, path: signed.path, sha256: signed.sha256, content: text(['show', `${sha}:${signed.path}`]) });
134
+ if (item.status !== 'verified') errors.push(`WENDKEEP_COMMIT_EVIDENCE_STATUS_INVALID: ${signed.path}`);
135
+ }
136
+ const adr = message.match(/^ADR:\s*(ADR-\d{4,})$/m)?.[1] || '';
137
+ const design = nativeDesignReference(message);
138
+ const authority = adr
139
+ ? { kind: 'adr', adr, ref: entries.find((entry) => entry.kind === 'adr')?.path || '', issue: message.match(/^Refs:\s*(#\d+)$/m)?.[1] || '' }
140
+ : { kind: 'native', issue: message.match(/^Issue:\s*(#\d+)$/m)?.[1] || '', design };
141
+ try {
142
+ const config = commitJson(sha, '.wendkeep.json') || {};
143
+ const executionProof = collectSensorsAtCommit(sha, entries);
144
+ const snapshot = {
145
+ head_sha: parents(sha)[0] || sha,
146
+ index_tree_sha: text(['show', '-s', '--format=%T', sha]).trim(),
147
+ };
148
+ const resolved = validateCommitProofSet({
149
+ entries, authority, stagedHash: scope.sha256,
150
+ context: {
151
+ projectId: config.projectId || '',
152
+ changeSlug: adr ? adr.toLowerCase() : `issue-${authority.issue.slice(1)}`,
153
+ baseSha: snapshot.base_sha,
154
+ headSha: snapshot.head_sha,
155
+ indexTreeSha: snapshot.index_tree_sha,
156
+ worktreeDigest: snapshot.worktree_digest,
157
+ dirty: snapshot.dirty,
158
+ sensorConfigSha256: executionProof?.configSha256,
159
+ executionProof,
160
+ profile: String(config?.harness?.profile || 'OFF').toUpperCase(),
161
+ },
162
+ });
163
+ if (JSON.stringify(messageTasks(message)) !== JSON.stringify(resolved.tasks)) errors.push('WENDKEEP_COMMIT_TASKS_MISMATCH');
164
+ if (JSON.stringify(messageTests(message)) !== JSON.stringify(resolved.tests)) errors.push('WENDKEEP_COMMIT_TESTS_MISMATCH');
165
+ } catch (error) {
166
+ errors.push(`${error.code || 'WENDKEEP_COMMIT_EVIDENCE_UNVERIFIED'}: ${error.message}`);
167
+ }
168
+ return errors;
169
+ }
170
+
171
+ function authorityErrors(sha, message) {
172
+ const errors = [];
173
+ const design = nativeDesignReference(message);
174
+ if (!design) return errors;
175
+ if (!objectExists(`${sha}:${design}`)) return [`WENDKEEP_COMMIT_DESIGN_UNVERSIONED: ${design}`];
176
+ const issue = message.match(/^Issue:\s*(#\d+)$/m)?.[1] || '';
177
+ const content = text(['show', `${sha}:${design}`]);
178
+ if (!content.includes(issue)) errors.push('WENDKEEP_COMMIT_NATIVE_ISSUE_UNVERIFIED');
179
+ const config = commitJson(sha, '.wendkeep.json');
180
+ if (String(config?.harness?.profile || '').toUpperCase() !== 'OFF') errors.push('WENDKEEP_COMMIT_NATIVE_PROFILE_REQUIRED');
181
+ const adrPaths = text(['ls-tree', '-r', '--name-only', sha]).split(/\r?\n/)
182
+ .filter((path) => /ADR-\d+.*\.md$/i.test(path));
183
+ if (adrPaths.some((path) => {
184
+ const adr = text(['show', `${sha}:${path}`]);
185
+ return adr.includes(issue) && adr.includes(design.split('/').at(-1));
186
+ })) errors.push('WENDKEEP_COMMIT_CAUSAL_AUTHORITY_EXISTS');
187
+ return errors;
188
+ }
189
+
190
+ function configuredPrivacyErrors(sha, message) {
191
+ const config = commitJson(sha, '.wendkeep.json');
192
+ const vault = typeof config?.vault === 'string' ? config.vault.replaceAll('\\', '/').trim() : '';
193
+ if (!vault) return [];
194
+ const name = vault.split('/').filter(Boolean).at(-1) || '';
195
+ const lower = message.toLowerCase();
196
+ return [vault, name].some((marker) => marker.length >= 3 && lower.includes(marker.toLowerCase()))
197
+ ? ['WENDKEEP_COMMIT_PRIVATE_PATH: message references the configured project Vault'] : [];
198
+ }
199
+
200
+ const base = option(process.argv.slice(2), '--base') || process.env.WENDKEEP_COMMIT_BASE || '';
201
+ const head = option(process.argv.slice(2), '--head') || process.env.WENDKEEP_COMMIT_HEAD || 'HEAD';
202
+ if (!base) {
203
+ process.stderr.write('WENDKEEP_COMMIT_ARGUMENT: --base is required\n');
204
+ process.exit(2);
205
+ }
206
+
207
+ const commits = text(['rev-list', '--reverse', `${base}..${head}`]).trim().split(/\r?\n/).filter(Boolean);
208
+ const failures = [];
209
+ for (const sha of commits) {
210
+ const message = text(['show', '-s', '--format=%B', sha]);
211
+ const subject = message.split(/\r?\n/, 1)[0];
212
+ const commitParents = parents(sha);
213
+ const errors = [...configuredPrivacyErrors(sha, message)];
214
+ if (commitParents.length > 1) {
215
+ errors.push(...mergeErrors(sha, message));
216
+ } else {
217
+ const result = validateCommitMessage(message);
218
+ errors.push(...result.errors);
219
+ if (messageEvidence(message).some((item) => (
220
+ item.status !== 'verified' || ['evidence', 'receipt', 'verdict'].includes(item.kind)
221
+ ))) errors.push('WENDKEEP_COMMIT_REMOTE_PROOF_UNAVAILABLE');
222
+ const files = changedFiles(sha);
223
+ if (!result.governed && !trivialCommit(subject, files)) errors.push('WENDKEEP_COMMIT_PRODUCT_CHANGE_UNGOVERNED');
224
+ if (result.governed && result.ok) {
225
+ const expected = scopeFor(sha);
226
+ const observed = messageScope(message);
227
+ if (expected.sha256 !== observed.sha256 || JSON.stringify(expected.files) !== JSON.stringify(observed.files)) {
228
+ errors.push('WENDKEEP_COMMIT_SCOPE_MISMATCH');
229
+ }
230
+ errors.push(...evidenceErrors(sha, message, expected), ...authorityErrors(sha, message));
231
+ }
232
+ }
233
+ if (errors.length) failures.push({ sha, subject, errors: [...new Set(errors)] });
234
+ }
235
+
236
+ if (failures.length) {
237
+ process.stderr.write(`WENDKEEP_COMMIT_RANGE_INVALID: ${failures.length} invalid commit(s)\n`);
238
+ for (const failure of failures) {
239
+ process.stderr.write(`${failure.sha.slice(0, 12)} ${failure.subject}\n`);
240
+ for (const error of failure.errors) process.stderr.write(` - ${error}\n`);
241
+ }
242
+ process.exit(1);
243
+ }
244
+ process.stdout.write(`${commits.length} commit(s) valid\n`);
package/src/doctor.mjs CHANGED
@@ -4,6 +4,11 @@ import { resolve } from 'node:path';
4
4
  import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines, checkSessionObservability, renderSessionObservabilityLines } from '../hooks/harness-doctor.mjs';
5
5
  import { diagnoseManagedWorktrees } from './worktree.mjs';
6
6
  import { runVaultHealth } from '../hooks/vault-health.mjs';
7
+ import {
8
+ inspectEvidenceSearchHealth,
9
+ renderEvidenceSearchHealthLines,
10
+ } from './evidence-search-health.mjs';
11
+ import { augmentVaultHealthWithMemoryScale } from './memory-scale-health.mjs';
7
12
  import { checkSyncDefs } from './sync-defs.mjs';
8
13
  import { resolveProjectVault } from './project-vault.mjs';
9
14
  import { inspectObserverSqlOutbox } from './observer-sql-publish.mjs';
@@ -14,11 +19,18 @@ import {
14
19
  import { inspectPortableState } from './portable.mjs';
15
20
  import { inspectSyncOutbox, readLocalSyncState } from './sync-outbox.mjs';
16
21
  import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
22
+ import { inspectGitCommitHooks } from './git-commit-hooks.mjs';
17
23
 
18
24
  const healthStatusLabel = (status) => ({
19
25
  healthy: 'saudável', warning: 'atenção', degraded: 'degradada', blocked: 'bloqueada', legacy: 'legado',
20
26
  }[status] || status || 'desconhecido');
21
27
 
28
+ const artifactStatusLabel = (status) => ({
29
+ ok: 'saudável', healthy: 'saudável', warning: 'atenção', degraded: 'degradado',
30
+ missing: 'ausente', empty: 'vazio', invalid: 'inválido', corrupt: 'corrompido',
31
+ blocked: 'bloqueado', unknown: 'desconhecido',
32
+ }[status] || status || 'desconhecido');
33
+
22
34
  const metricValue = (value) => value === null || value === undefined || value === '' ? 'n/a' : value;
23
35
 
24
36
  export function renderVaultHealthLines(result) {
@@ -49,6 +61,19 @@ export function renderVaultHealthLines(result) {
49
61
  );
50
62
  const repairableHandoffs = Number(memory.repairableHandoffs || 0);
51
63
  lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}${repairableHandoffs ? ` · handoffs reparáveis: ${repairableHandoffs}` : ''}`);
64
+ if (memory.scaleSchemaVersion === 1) {
65
+ lines.push(
66
+ ` replay: snapshot ${artifactStatusLabel(memory.snapshotStatus)} · cobertos: ${metricValue(memory.snapshotEvents)} evento(s) · tail: ${metricValue(memory.snapshotTailEvents)} evento(s)/${metricValue(memory.snapshotTailBytes)} bytes · ledger no snapshot: ${metricValue(memory.snapshotLedgerBytes)} bytes`,
67
+ );
68
+ if (memory.snapshotReason) lines.push(` ↳ snapshot: ${memory.snapshotReason}`);
69
+ lines.push(
70
+ ` segmentos: ${artifactStatusLabel(memory.segmentStatus)} · ${metricValue(memory.segmentCount)} segmento(s) · cobertos: ${metricValue(memory.segmentCoveredEvents)} evento(s)/${metricValue(memory.segmentCoveredBytes)} bytes · pendentes: ${metricValue(memory.segmentPendingEvents)}`,
71
+ );
72
+ lines.push(
73
+ ` rotação: geração ${artifactStatusLabel(memory.generationStatus)} #${metricValue(memory.generation)} · origem: ${metricValue(memory.generationSourceEvents)} · tail ativo: ${metricValue(memory.generationActiveTailEvents)} · journal: ${artifactStatusLabel(memory.rotationJournal)} · receipts: ${metricValue(memory.rotationReceipts)} (${artifactStatusLabel(memory.rotationReceiptCheckpoint)})`,
74
+ );
75
+ if (memory.scaleErrorCode) lines.push(` ↳ escala: ${memory.scaleErrorCode}`);
76
+ }
52
77
  const semanticKeys = memory.semanticActiveKeys || [];
53
78
  const semanticProjected = memory.semanticProjectedKeys || [];
54
79
  const semanticMissing = memory.semanticMissingKeys || [];
@@ -105,7 +130,10 @@ export function runDoctor(argv) {
105
130
  // 1. Session/vault integrity. The standalone hook remains JSON; doctor renders it for humans.
106
131
  let health;
107
132
  try {
108
- health = runVaultHealth({ vaultBase, session });
133
+ health = augmentVaultHealthWithMemoryScale(
134
+ runVaultHealth({ vaultBase, session }),
135
+ vaultBase,
136
+ );
109
137
  } catch (error) {
110
138
  health = {
111
139
  ok: false,
@@ -116,16 +144,24 @@ export function runDoctor(argv) {
116
144
  memoryStatus: 'blocked',
117
145
  };
118
146
  }
119
- if (scope !== 'runtime') process.stdout.write(`${renderVaultHealthLines(health).join('\n')}\n`);
147
+ const recall = scope === 'runtime'
148
+ ? { status: 'skipped' }
149
+ : inspectEvidenceSearchHealth(vaultBase);
150
+ if (scope !== 'runtime') {
151
+ process.stdout.write(`${renderVaultHealthLines(health).join('\n')}\n`);
152
+ process.stdout.write(`${renderEvidenceSearchHealthLines(recall).join('\n')}\n`);
153
+ }
120
154
  const healthStatus = health.ok ? 0 : 1;
155
+ const recallStatus = recall.status === 'blocked' ? 1 : 0;
121
156
 
122
157
  if (scope === 'core') {
123
158
  const strictDebt = strict && (
124
159
  (health.warnings || []).length > 0
125
160
  || !['healthy'].includes(health.memoryStatus)
161
+ || !['healthy', 'missing'].includes(recall.status)
126
162
  );
127
- process.stdout.write(`\n[core] ${healthStatus ? 'erro estrutural' : health.memoryStatus === 'degraded' ? 'saudável com memória degradada' : 'saudável'}\n`);
128
- return healthStatus || strictDebt ? 1 : 0;
163
+ process.stdout.write(`\n[core] ${healthStatus || recallStatus ? 'erro estrutural' : health.memoryStatus === 'degraded' ? 'saudável com memória degradada' : 'saudável'}\n`);
164
+ return healthStatus || recallStatus || strictDebt ? 1 : 0;
129
165
  }
130
166
 
131
167
  // 2. Harness integrity (Wave B).
@@ -147,6 +183,11 @@ export function runDoctor(argv) {
147
183
  process.stdout.write(` → ${issue.slug}: ${issue.errorCode} — ${issue.repair}\n`);
148
184
  }
149
185
 
186
+ const commitHooks = inspectGitCommitHooks({ projectRoot });
187
+ process.stdout.write(`\n[commit-hooks] ${commitHooks.status}\n`);
188
+ for (const issue of commitHooks.issues) process.stdout.write(` ! ${issue}\n`);
189
+ if (commitHooks.repair) process.stdout.write(` → ${commitHooks.repair}\n`);
190
+
150
191
  const activeContexts = inspectActiveContextHealth({ vaultBase, projectRoot });
151
192
  process.stdout.write(`\n${renderActiveContextHealthLines(activeContexts).join('\n')}\n`);
152
193
 
@@ -217,10 +258,12 @@ export function runDoctor(argv) {
217
258
  const strictDebt = strict && (
218
259
  (scope !== 'runtime' && (health.warnings || []).length)
219
260
  || (scope !== 'runtime' && health.memoryStatus !== 'healthy')
261
+ || (scope !== 'runtime' && !['healthy', 'missing'].includes(recall.status))
220
262
  || attention.length
221
263
  || repairable.length
222
264
  || warnings.length
223
265
  || worktrees.issues.length
266
+ || (commitHooks.configured && commitHooks.status !== 'healthy')
224
267
  || activeContexts.issues.length
225
268
  || ['diverged', 'invalid'].includes(portable.status)
226
269
  || sync.status === 'corrupt'
@@ -233,5 +276,5 @@ export function runDoctor(argv) {
233
276
  || (staleDerived.notes || staleDerived.items || []).length
234
277
  || !observability.ok
235
278
  );
236
- return (scope !== 'runtime' && healthStatus !== 0) || errors.length || strictDebt ? 1 : 0;
279
+ return (scope !== 'runtime' && (healthStatus !== 0 || recallStatus !== 0)) || errors.length || strictDebt ? 1 : 0;
237
280
  }
@@ -0,0 +1,221 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ EVIDENCE_INDEX_FILE,
6
+ } from '../packages/vault/src/evidence-recall.mjs';
7
+ import {
8
+ EVIDENCE_INDEX_STATE_FILE,
9
+ loadEvidenceIndexState,
10
+ } from '../packages/vault/src/evidence-index-store.mjs';
11
+ import {
12
+ EVIDENCE_SEARCH_STATE_FILE,
13
+ evidenceSearchSqliteAvailable,
14
+ loadEvidenceSearchState,
15
+ } from '../packages/vault/src/evidence-search-index.mjs';
16
+ import { assertVaultPathSafe } from '../packages/vault/src/vault-path-safety.mjs';
17
+
18
+ export const EVIDENCE_SEARCH_HEALTH_SCHEMA_VERSION = 1;
19
+
20
+ function brainDir(vaultBase) {
21
+ return join(vaultBase, '.brain');
22
+ }
23
+
24
+ function nsText(value, fallbackMs = 0) {
25
+ if (typeof value === 'bigint') return value.toString();
26
+ return BigInt(Math.max(0, Math.trunc(Number(fallbackMs || 0) * 1_000_000))).toString();
27
+ }
28
+
29
+ function fileInfo(vaultBase, path, label) {
30
+ let checked = assertVaultPathSafe(vaultBase, path, {
31
+ expectedType: 'file',
32
+ label,
33
+ });
34
+ if (!checked.exists) return { exists: false, bytes: 0, fingerprint: null };
35
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
36
+ allowMissing: false,
37
+ expectedType: 'file',
38
+ label,
39
+ });
40
+ const stat = statSync(checked.target, { bigint: true });
41
+ return {
42
+ exists: true,
43
+ bytes: Number(stat.size),
44
+ fingerprint: {
45
+ size: stat.size.toString(),
46
+ mtime_ns: nsText(stat.mtimeNs, stat.mtimeMs),
47
+ ctime_ns: nsText(stat.ctimeNs, stat.ctimeMs),
48
+ },
49
+ };
50
+ }
51
+
52
+ function sameFingerprint(left, right) {
53
+ if (left === null || right === null) return left === right;
54
+ return Boolean(left && right)
55
+ && String(left.size) === String(right.size)
56
+ && String(left.mtime_ns) === String(right.mtime_ns)
57
+ && String(left.ctime_ns) === String(right.ctime_ns);
58
+ }
59
+
60
+ function safeCode(value, fallback = '') {
61
+ const text = String(value || fallback).trim();
62
+ return text
63
+ ? text.replace(/[^A-Za-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120)
64
+ : null;
65
+ }
66
+
67
+ function artifactInfo(vaultBase, artifact, kind) {
68
+ if (!artifact) {
69
+ return { status: 'not-built', bytes: 0, current: false };
70
+ }
71
+ const path = join(brainDir(vaultBase), ...String(artifact.path || '').split('/'));
72
+ const file = fileInfo(vaultBase, path, `artefato ${kind} da busca de evidências`);
73
+ if (!file.exists) return { status: 'missing', bytes: 0, current: false };
74
+ const current = sameFingerprint(artifact.fingerprint, file.fingerprint);
75
+ return {
76
+ status: current ? 'current' : 'stale',
77
+ bytes: file.bytes,
78
+ current,
79
+ };
80
+ }
81
+
82
+ export function emptyEvidenceSearchHealth() {
83
+ return {
84
+ schemaVersion: EVIDENCE_SEARCH_HEALTH_SCHEMA_VERSION,
85
+ status: 'unknown',
86
+ errorCode: null,
87
+ authorityStatus: 'unknown',
88
+ authorityBytes: 0,
89
+ incrementalStateStatus: 'unknown',
90
+ incrementalStateBytes: 0,
91
+ documentCount: 0,
92
+ searchStateStatus: 'unknown',
93
+ searchStateBytes: 0,
94
+ rowCount: 0,
95
+ sourceIndexCurrent: false,
96
+ sourceStateCurrent: false,
97
+ lexicalStatus: 'unknown',
98
+ lexicalBytes: 0,
99
+ sqliteStatus: 'unknown',
100
+ sqliteBytes: 0,
101
+ sqliteCapability: false,
102
+ backend: 'unavailable',
103
+ };
104
+ }
105
+
106
+ function deriveStatus(metrics) {
107
+ if (metrics.authorityStatus === 'missing') return 'missing';
108
+ if (metrics.incrementalStateStatus === 'invalid'
109
+ || metrics.searchStateStatus === 'invalid') return 'degraded';
110
+ if (metrics.searchStateStatus !== 'current') return 'warning';
111
+ if (metrics.lexicalStatus !== 'current') return 'degraded';
112
+ return 'healthy';
113
+ }
114
+
115
+ export function inspectEvidenceSearchHealth(vaultBase) {
116
+ const metrics = emptyEvidenceSearchHealth();
117
+ try {
118
+ const brain = brainDir(vaultBase);
119
+ const authority = fileInfo(
120
+ vaultBase,
121
+ join(brain, EVIDENCE_INDEX_FILE),
122
+ 'autoridade EVIDENCE_INDEX.jsonl',
123
+ );
124
+ const incrementalFile = fileInfo(
125
+ vaultBase,
126
+ join(brain, EVIDENCE_INDEX_STATE_FILE),
127
+ 'estado incremental EVIDENCE_INDEX_STATE.json',
128
+ );
129
+ const searchFile = fileInfo(
130
+ vaultBase,
131
+ join(brain, EVIDENCE_SEARCH_STATE_FILE),
132
+ 'estado derivado EVIDENCE_SEARCH_STATE.json',
133
+ );
134
+
135
+ metrics.authorityStatus = authority.exists ? 'present' : 'missing';
136
+ metrics.authorityBytes = authority.bytes;
137
+ metrics.incrementalStateBytes = incrementalFile.bytes;
138
+ metrics.searchStateBytes = searchFile.bytes;
139
+ metrics.sqliteCapability = evidenceSearchSqliteAvailable();
140
+
141
+ const incremental = incrementalFile.exists ? loadEvidenceIndexState(vaultBase) : null;
142
+ metrics.incrementalStateStatus = incrementalFile.exists
143
+ ? (incremental ? 'ok' : 'invalid')
144
+ : 'missing';
145
+ metrics.documentCount = incremental
146
+ ? Object.keys(incremental.documents || {}).length
147
+ : 0;
148
+
149
+ const state = searchFile.exists ? loadEvidenceSearchState(vaultBase) : null;
150
+ if (!searchFile.exists) {
151
+ metrics.searchStateStatus = 'missing';
152
+ metrics.lexicalStatus = 'not-built';
153
+ metrics.sqliteStatus = 'not-built';
154
+ metrics.backend = authority.exists ? 'lexical-ephemeral' : 'unavailable';
155
+ metrics.status = deriveStatus(metrics);
156
+ return metrics;
157
+ }
158
+ if (!state) {
159
+ metrics.searchStateStatus = 'invalid';
160
+ metrics.lexicalStatus = 'unknown';
161
+ metrics.sqliteStatus = 'unknown';
162
+ metrics.backend = authority.exists ? 'lexical-ephemeral' : 'unavailable';
163
+ metrics.status = deriveStatus(metrics);
164
+ return metrics;
165
+ }
166
+
167
+ metrics.rowCount = Number(state.row_count || 0);
168
+ metrics.sourceIndexCurrent = sameFingerprint(state.source?.index, authority.fingerprint);
169
+ metrics.sourceStateCurrent = sameFingerprint(
170
+ state.source?.state ?? null,
171
+ incrementalFile.fingerprint,
172
+ );
173
+ const lexical = artifactInfo(vaultBase, state.lexical, 'lexical');
174
+ const sqlite = artifactInfo(vaultBase, state.sqlite, 'SQLite FTS');
175
+ metrics.lexicalStatus = lexical.status;
176
+ metrics.lexicalBytes = lexical.bytes;
177
+ metrics.sqliteStatus = sqlite.status;
178
+ metrics.sqliteBytes = sqlite.bytes;
179
+
180
+ const sourceCurrent = metrics.sourceIndexCurrent && metrics.sourceStateCurrent;
181
+ const artifactsCurrent = lexical.current && (!state.sqlite || sqlite.current);
182
+ metrics.searchStateStatus = sourceCurrent && artifactsCurrent ? 'current' : 'stale';
183
+ metrics.backend = metrics.searchStateStatus === 'current'
184
+ && sqlite.current
185
+ && metrics.sqliteCapability
186
+ ? 'sqlite-fts5'
187
+ : metrics.searchStateStatus === 'current' && lexical.current
188
+ ? 'lexical-sidecar'
189
+ : authority.exists
190
+ ? 'lexical-ephemeral'
191
+ : 'unavailable';
192
+ metrics.status = deriveStatus(metrics);
193
+ return metrics;
194
+ } catch (error) {
195
+ return {
196
+ ...metrics,
197
+ status: 'blocked',
198
+ errorCode: safeCode(error?.code, 'EVIDENCE_SEARCH_HEALTH_UNSAFE'),
199
+ };
200
+ }
201
+ }
202
+
203
+ const statusLabel = (status) => ({
204
+ healthy: 'saudável', warning: 'atenção', degraded: 'degradado', blocked: 'bloqueado',
205
+ missing: 'ausente', current: 'atual', stale: 'stale', present: 'presente', ok: 'saudável',
206
+ invalid: 'inválido', 'not-built': 'não construído', unknown: 'desconhecido',
207
+ }[status] || status || 'desconhecido');
208
+
209
+ export function renderEvidenceSearchHealthLines(metrics) {
210
+ const lines = [
211
+ `[recall] ${statusLabel(metrics.status)} — backend: ${metrics.backend} · SQLite/FTS5: ${metrics.sqliteCapability ? 'disponível' : 'indisponível'}`,
212
+ ` autoridade: ${statusLabel(metrics.authorityStatus)} · ${metrics.authorityBytes} bytes · documentos: ${metrics.documentCount} · chunks: ${metrics.rowCount}`,
213
+ ` incremental: ${statusLabel(metrics.incrementalStateStatus)} · ${metrics.incrementalStateBytes} bytes · busca: ${statusLabel(metrics.searchStateStatus)} · ${metrics.searchStateBytes} bytes`,
214
+ ` lexical: ${statusLabel(metrics.lexicalStatus)} · ${metrics.lexicalBytes} bytes · SQLite: ${statusLabel(metrics.sqliteStatus)} · ${metrics.sqliteBytes} bytes`,
215
+ ];
216
+ if (metrics.searchStateStatus === 'stale' || metrics.searchStateStatus === 'missing') {
217
+ lines.push(' ! o próximo recall pode reconstruir o índice derivado a partir da autoridade JSONL');
218
+ }
219
+ if (metrics.errorCode) lines.push(` ✗ ${metrics.errorCode}`);
220
+ return lines;
221
+ }