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.
- package/.githooks/commit-msg +16 -0
- package/.githooks/prepare-commit-msg +16 -0
- package/CHANGELOG.md +40 -0
- package/README.en.md +4 -1
- package/README.md +4 -1
- package/docs/en/commands/commit.md +159 -0
- package/docs/en/commands/evidence-embeddings.md +243 -0
- package/docs/en/commands/mcp.md +67 -7
- package/docs/pt-BR/commands/commit.md +159 -0
- package/docs/pt-BR/commands/evidence-embeddings.md +244 -0
- package/docs/pt-BR/commands/mcp.md +66 -7
- package/hooks/evidence-context.mjs +41 -7
- package/hooks/evidence-recall.mjs +10 -0
- package/package.json +5 -2
- package/packages/cli/src/index.mjs +11 -1
- package/packages/commit/package.json +6 -0
- package/packages/commit/src/cli.mjs +89 -0
- package/packages/commit/src/commit-input.mjs +181 -0
- package/packages/commit/src/commit-message.mjs +51 -0
- package/packages/commit/src/commit-policy.mjs +144 -0
- package/packages/commit/src/git-runtime.mjs +428 -0
- package/packages/commit/src/index.mjs +28 -0
- package/packages/commit/src/proof-validation.mjs +443 -0
- package/packages/mcp/src/effects.mjs +3 -2
- package/packages/mcp/src/evidence-recall.mjs +130 -0
- package/packages/mcp/src/executor.mjs +4 -0
- package/packages/mcp/src/server.mjs +31 -1
- package/packages/vault/src/evidence-embedding-plugin.mjs +531 -0
- package/packages/vault/src/evidence-index-store.mjs +360 -0
- package/packages/vault/src/evidence-recall-page.mjs +381 -0
- package/packages/vault/src/evidence-search-index.mjs +917 -0
- package/packages/vault/src/index.mjs +12 -1
- package/packages/vault/src/memory-ledger-view-base.mjs +545 -0
- package/packages/vault/src/memory-ledger-view.mjs +41 -0
- package/packages/vault/src/memory-rotation-store.mjs +967 -0
- package/packages/vault/src/memory-segment-store.mjs +820 -0
- package/packages/vault/src/memory-snapshot-store.mjs +1105 -0
- package/packages/vault/src/memory-store-base.mjs +1161 -0
- package/packages/vault/src/memory-store-core.mjs +2 -0
- package/packages/vault/src/memory-store.mjs +46 -1161
- package/schema/commit-message-v1.schema.json +75 -0
- package/scripts/validate-commit-range.mjs +244 -0
- package/src/doctor.mjs +48 -5
- package/src/evidence-search-health.mjs +221 -0
- package/src/git-commit-hooks.mjs +112 -0
- package/src/init.mjs +13 -0
- package/src/memory-scale-health.mjs +210 -0
- package/src/observer-snapshot.mjs +87 -1
- 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 =
|
|
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
|
-
|
|
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
|
+
}
|