wendkeep 0.72.1 → 0.74.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 +61 -0
- package/README.en.md +37 -16
- package/README.md +37 -16
- package/docs/en/commands/changes-and-verification.md +10 -5
- package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
- package/docs/en/commands/memory.md +16 -1
- package/docs/en/commands/observer.md +8 -1
- package/docs/en/commands/operating-profiles.md +28 -3
- package/docs/pt-BR/commands/changes-and-verification.md +10 -5
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
- package/docs/pt-BR/commands/memory.md +16 -1
- package/docs/pt-BR/commands/observer.md +7 -1
- package/docs/pt-BR/commands/operating-profiles.md +28 -3
- package/hooks/brain-core.mjs +2 -0
- package/hooks/brain-inject.mjs +6 -6
- package/hooks/brain-recall.mjs +5 -1
- package/hooks/change-context.mjs +11 -0
- package/hooks/change-core.mjs +53 -21
- package/hooks/change-warn.mjs +2 -0
- package/hooks/evidence-context.mjs +41 -0
- package/hooks/evidence-recall.mjs +1 -0
- package/hooks/harness-doctor.mjs +13 -5
- package/hooks/memory-scope.mjs +1 -0
- package/hooks/vault-health.mjs +2 -2
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +13 -3
- package/packages/integrations/src/host-hooks.mjs +1 -0
- package/packages/vault/src/evidence-recall.mjs +343 -0
- package/packages/vault/src/index.mjs +2 -0
- package/packages/vault/src/memory-handoff.mjs +58 -3
- package/packages/vault/src/memory-schema.mjs +12 -2
- package/packages/vault/src/memory-scope.mjs +119 -0
- package/packages/vault/src/memory-store.mjs +86 -24
- package/schema/observer/004-evidence-recall.sql +25 -0
- package/src/change.mjs +10 -4
- package/src/delivery.mjs +303 -0
- package/src/doctor.mjs +47 -10
- package/src/memory.mjs +95 -2
- package/src/observer-sql-store.mjs +141 -5
- package/src/release-provenance.mjs +47 -0
- package/src/skills-seed.mjs +25 -9
- package/src/sync-defs.mjs +5 -2
- package/src/sync.mjs +2 -2
- package/src/taxonomy.mjs +4 -0
- package/src/work-kind.mjs +62 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
4
|
import { basename, join, relative } from 'node:path';
|
|
4
5
|
|
|
5
6
|
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
7
|
+
import { scopeForMemoryKey } from './memory-scope.mjs';
|
|
6
8
|
|
|
7
9
|
const SHARED_HANDOFF_FIELDS = Object.freeze([
|
|
8
10
|
['objective', 'objective.current'],
|
|
@@ -43,6 +45,10 @@ export function normalizeSharedHandoff(shared) {
|
|
|
43
45
|
const normalized = {};
|
|
44
46
|
const workSessionId = sanitizeMemoryText(shared.work_session_id ?? shared.workSessionId ?? '').trim();
|
|
45
47
|
if (workSessionId) normalized.work_session_id = workSessionId;
|
|
48
|
+
for (const field of ['branch', 'worktree_id', 'repository_id', 'change_slug', 'tasks_hash', 'spec_hash']) {
|
|
49
|
+
const value = sanitizeMemoryText(shared[field] ?? '').trim();
|
|
50
|
+
if (value) normalized[field] = value;
|
|
51
|
+
}
|
|
46
52
|
|
|
47
53
|
for (const [field] of SHARED_HANDOFF_FIELDS) {
|
|
48
54
|
if (!Object.hasOwn(shared, field)) continue;
|
|
@@ -53,7 +59,7 @@ export function normalizeSharedHandoff(shared) {
|
|
|
53
59
|
return Object.keys(normalized).length ? normalized : null;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function eventId(context, memoryKey, value) {
|
|
62
|
+
function eventId(context, memoryKey, value, scope = null) {
|
|
57
63
|
const digest = createHash('sha256')
|
|
58
64
|
.update(JSON.stringify([
|
|
59
65
|
context.projectId,
|
|
@@ -61,6 +67,7 @@ function eventId(context, memoryKey, value) {
|
|
|
61
67
|
context.activation?.id,
|
|
62
68
|
context.turn?.id,
|
|
63
69
|
memoryKey,
|
|
70
|
+
scope,
|
|
64
71
|
canonicalValue(value),
|
|
65
72
|
]))
|
|
66
73
|
.digest('hex')
|
|
@@ -68,13 +75,15 @@ function eventId(context, memoryKey, value) {
|
|
|
68
75
|
return `mem-${digest}`;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
function makeEvent(context, { memoryKey, value, authority, evidence }) {
|
|
78
|
+
function makeEvent(context, { memoryKey, value, authority, evidence, scopeContext = {} }) {
|
|
72
79
|
const cleanValue = sanitizeValue(value);
|
|
80
|
+
const scope = scopeForMemoryKey(memoryKey, { ...context, ...scopeContext });
|
|
73
81
|
const event = {
|
|
74
82
|
v: 1,
|
|
75
|
-
event_id: eventId(context, memoryKey, cleanValue),
|
|
83
|
+
event_id: eventId(context, memoryKey, cleanValue, scope),
|
|
76
84
|
project_id: String(context.projectId || ''),
|
|
77
85
|
memory_key: memoryKey,
|
|
86
|
+
scope,
|
|
78
87
|
operation: 'assert',
|
|
79
88
|
value: cleanValue,
|
|
80
89
|
authority,
|
|
@@ -118,6 +127,26 @@ function nextActionFrom(summary) {
|
|
|
118
127
|
return id && text ? { id, summary: text } : null;
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
function gitScope(cwd = process.cwd(), spawn = spawnSync) {
|
|
131
|
+
const run = (args) => {
|
|
132
|
+
const result = spawn('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
133
|
+
return result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
const branch = run(['branch', '--show-current']) || `detached:${run(['rev-parse', '--short=12', 'HEAD'])}`;
|
|
137
|
+
const gitDir = run(['rev-parse', '--absolute-git-dir']);
|
|
138
|
+
const remote = run(['remote', 'get-url', 'origin']) || run(['rev-parse', '--show-toplevel']);
|
|
139
|
+
if (!branch || !gitDir || !remote) return null;
|
|
140
|
+
return {
|
|
141
|
+
branch,
|
|
142
|
+
worktree_id: createHash('sha256').update(gitDir).digest('hex').slice(0, 16),
|
|
143
|
+
repository_id: createHash('sha256').update(remote).digest('hex').slice(0, 16),
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary = '', noteRel = '' } = {}) {
|
|
122
151
|
const evidence = {};
|
|
123
152
|
const slug = String(changeSlug || '').trim();
|
|
@@ -159,11 +188,13 @@ export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary =
|
|
|
159
188
|
if (nextAction) evidence.nextAction = nextAction;
|
|
160
189
|
const commit = String(summary || '').match(/\b[0-9a-f]{40}\b/i)?.[0];
|
|
161
190
|
if (commit) {
|
|
191
|
+
const scope = gitScope();
|
|
162
192
|
evidence.git = {
|
|
163
193
|
commit: commit.toLowerCase(),
|
|
164
194
|
pushed: !/(?:nenhum|sem)\s+push/i.test(String(summary || '')),
|
|
165
195
|
verified: false,
|
|
166
196
|
path: noteRel,
|
|
197
|
+
...(scope || {}),
|
|
167
198
|
};
|
|
168
199
|
}
|
|
169
200
|
return evidence;
|
|
@@ -184,6 +215,14 @@ export function buildSessionMemoryEvents({
|
|
|
184
215
|
const context = {
|
|
185
216
|
projectId, identity, activation, turn, observedAt,
|
|
186
217
|
workSessionId: normalizedShared?.work_session_id || '',
|
|
218
|
+
canonicalSessionId: identity?.canonicalConversationId || '',
|
|
219
|
+
activation_id: activation?.id || '',
|
|
220
|
+
branch: normalizedShared?.branch || '',
|
|
221
|
+
worktreeId: normalizedShared?.worktree_id || '',
|
|
222
|
+
repositoryId: normalizedShared?.repository_id || '',
|
|
223
|
+
changeSlug: normalizedShared?.change_slug || evidence.change?.slug || '',
|
|
224
|
+
tasksHash: normalizedShared?.tasks_hash || '',
|
|
225
|
+
specHash: normalizedShared?.spec_hash || '',
|
|
187
226
|
};
|
|
188
227
|
const events = [];
|
|
189
228
|
|
|
@@ -195,6 +234,7 @@ export function buildSessionMemoryEvents({
|
|
|
195
234
|
value: normalizedShared[field],
|
|
196
235
|
authority: 'reported',
|
|
197
236
|
evidence: [noteRel],
|
|
237
|
+
scopeContext: { changeSlug: evidence.change?.slug || normalizedShared?.change_slug },
|
|
198
238
|
}));
|
|
199
239
|
}
|
|
200
240
|
}
|
|
@@ -214,6 +254,7 @@ export function buildSessionMemoryEvents({
|
|
|
214
254
|
value: { status: evidence.change.status, adr: evidence.change.adr },
|
|
215
255
|
authority: 'verified',
|
|
216
256
|
evidence: [evidence.change.path || evidence.change.adr],
|
|
257
|
+
scopeContext: { changeSlug: evidence.change.slug },
|
|
217
258
|
}));
|
|
218
259
|
}
|
|
219
260
|
|
|
@@ -227,6 +268,11 @@ export function buildSessionMemoryEvents({
|
|
|
227
268
|
},
|
|
228
269
|
authority: 'verified',
|
|
229
270
|
evidence: [evidence.verdict.path],
|
|
271
|
+
scopeContext: {
|
|
272
|
+
changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
|
|
273
|
+
tasksHash: evidence.verdict.tasks_hash || normalizedShared?.tasks_hash,
|
|
274
|
+
specHash: evidence.verdict.spec_hash || normalizedShared?.spec_hash,
|
|
275
|
+
},
|
|
230
276
|
}));
|
|
231
277
|
}
|
|
232
278
|
|
|
@@ -236,6 +282,10 @@ export function buildSessionMemoryEvents({
|
|
|
236
282
|
value: [...new Set(evidence.sensors.map(String))].sort(),
|
|
237
283
|
authority: 'verified',
|
|
238
284
|
evidence: evidence.sensors,
|
|
285
|
+
scopeContext: {
|
|
286
|
+
changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
|
|
287
|
+
tasksHash: evidence.sensors_tasks_hash || normalizedShared?.tasks_hash,
|
|
288
|
+
},
|
|
239
289
|
}));
|
|
240
290
|
}
|
|
241
291
|
|
|
@@ -249,6 +299,11 @@ export function buildSessionMemoryEvents({
|
|
|
249
299
|
},
|
|
250
300
|
authority: evidence.git.verified === false ? 'reported' : 'verified',
|
|
251
301
|
evidence: [evidence.git.path || evidence.git.commit],
|
|
302
|
+
scopeContext: {
|
|
303
|
+
branch: evidence.git.branch || normalizedShared?.branch,
|
|
304
|
+
worktreeId: evidence.git.worktree_id || normalizedShared?.worktree_id,
|
|
305
|
+
repositoryId: evidence.git.repository_id || normalizedShared?.repository_id,
|
|
306
|
+
},
|
|
252
307
|
}));
|
|
253
308
|
}
|
|
254
309
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MEMORY_SCOPE_TYPES, normalizeMemoryScope } from './memory-scope.mjs';
|
|
2
3
|
|
|
3
4
|
export const SHARED_LIMITS = Object.freeze({ lines: 48, bytes: 6144, lineChars: 320 });
|
|
4
5
|
|
|
@@ -107,6 +108,11 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
107
108
|
if (projectId !== undefined && !eventBelongsToVault(event, projectId)) {
|
|
108
109
|
errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
|
|
109
110
|
}
|
|
111
|
+
if (event.scope !== undefined) {
|
|
112
|
+
if (!normalizeMemoryScope(event.scope, { projectId: event.project_id || projectId || '' })) {
|
|
113
|
+
errors.push(`scope deve conter type (${MEMORY_SCOPE_TYPES.join('|')}) e id não vazio compatível com o projeto.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
110
116
|
|
|
111
117
|
if (event.candidate_decision !== undefined) {
|
|
112
118
|
const decision = event.candidate_decision;
|
|
@@ -133,7 +139,7 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
|
|
142
|
+
for (const field of ['value', 'evidence', 'scope']) sanitizedField(event, field, errors);
|
|
137
143
|
return { ok: errors.length === 0, errors, warnings };
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -161,6 +167,7 @@ function hashProjection(events) {
|
|
|
161
167
|
operation: event.operation,
|
|
162
168
|
value: sanitizeMemoryText(event.value),
|
|
163
169
|
authority: event.authority,
|
|
170
|
+
scope: event.scope,
|
|
164
171
|
observed_at: event.observed_at,
|
|
165
172
|
evidence: Array.isArray(event.evidence) ? event.evidence.map(sanitizeMemoryText) : [],
|
|
166
173
|
}));
|
|
@@ -173,7 +180,10 @@ function eventLine(event) {
|
|
|
173
180
|
? event.evidence.map(sanitizeMemoryText).join(', ')
|
|
174
181
|
: 'none';
|
|
175
182
|
const source = sanitizeMemoryText(event.source_turn_id || event.canonical_session_id || event.activation_id || 'unknown');
|
|
176
|
-
const
|
|
183
|
+
const scope = event.scope?.type && event.scope?.id
|
|
184
|
+
? ` · scope:${sanitizeMemoryText(event.scope.type)}:${sanitizeMemoryText(event.scope.id)}`
|
|
185
|
+
: '';
|
|
186
|
+
const line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)}${scope} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
|
|
177
187
|
return line.length <= SHARED_LIMITS.lineChars
|
|
178
188
|
? line
|
|
179
189
|
: `${line.slice(0, SHARED_LIMITS.lineChars - 1).trimEnd()}…`;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MEMORY_SCOPE_TYPES = Object.freeze([
|
|
4
|
+
'project',
|
|
5
|
+
'work_session',
|
|
6
|
+
'change',
|
|
7
|
+
'branch',
|
|
8
|
+
'worktree',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const SCOPE_TYPES = new Set(MEMORY_SCOPE_TYPES);
|
|
12
|
+
const REGISTER_PATTERNS = [
|
|
13
|
+
/^git\.local-head$/,
|
|
14
|
+
/^handoff\.latest$/,
|
|
15
|
+
/^quality\.latest-(?:sensors|verdict)$/,
|
|
16
|
+
/^change\.[A-Za-z0-9._-]+\.status$/,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function clean(value) {
|
|
20
|
+
return String(value ?? '').trim().replace(/[\r\n\t]+/g, ' ').slice(0, 240);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function digest(value) {
|
|
24
|
+
return createHash('sha256').update(clean(value)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeMemoryScope(scope, { projectId = '' } = {}) {
|
|
28
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return null;
|
|
29
|
+
const type = clean(scope.type);
|
|
30
|
+
const id = clean(scope.id);
|
|
31
|
+
if (!SCOPE_TYPES.has(type) || !id) return null;
|
|
32
|
+
if (type === 'project' && projectId && id !== projectId) return null;
|
|
33
|
+
return { type, id };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function changeSlug(memoryKey, context) {
|
|
37
|
+
const fromKey = String(memoryKey || '').match(/^change\.([A-Za-z0-9._-]+)\.status$/)?.[1];
|
|
38
|
+
return clean(fromKey || context.changeSlug || context.change_slug);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function workSession(context) {
|
|
42
|
+
return clean(
|
|
43
|
+
context.workSessionId || context.work_session_id
|
|
44
|
+
|| context.canonicalSessionId || context.canonical_session_id
|
|
45
|
+
|| context.sessionId || context.session_id,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Deterministic scope policy for operational keys. It never uses an absolute local path. */
|
|
50
|
+
export function scopeForMemoryKey(memoryKey, context = {}) {
|
|
51
|
+
const key = clean(memoryKey);
|
|
52
|
+
const projectId = clean(context.projectId || context.project_id) || 'unknown-project';
|
|
53
|
+
if (key === 'handoff.latest') {
|
|
54
|
+
return { type: 'work_session', id: workSession(context) || `legacy:${projectId}` };
|
|
55
|
+
}
|
|
56
|
+
if (key === 'git.local-head') {
|
|
57
|
+
const branch = clean(context.branch || context.branchName || context.branch_name);
|
|
58
|
+
const worktree = clean(context.worktreeId || context.worktree_id);
|
|
59
|
+
const repository = clean(context.repositoryId || context.repository_id);
|
|
60
|
+
if (branch) {
|
|
61
|
+
return {
|
|
62
|
+
type: 'branch',
|
|
63
|
+
id: [repository && `repo:${repository}`, worktree && `worktree:${worktree}`, `branch:${branch}`]
|
|
64
|
+
.filter(Boolean).join('|'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const lineage = workSession(context) || clean(context.activation_id) || clean(context.event_id);
|
|
68
|
+
return { type: 'branch', id: `legacy:${projectId}:${digest(lineage || key)}` };
|
|
69
|
+
}
|
|
70
|
+
if (/^quality\.latest-(?:sensors|verdict)$/.test(key)) {
|
|
71
|
+
const slug = changeSlug(key, context) || `legacy:${digest(workSession(context) || key)}`;
|
|
72
|
+
const proof = clean(context.tasksHash || context.tasks_hash || context.specHash || context.spec_hash);
|
|
73
|
+
return { type: 'change', id: proof ? `${slug}|proof:${proof}` : slug };
|
|
74
|
+
}
|
|
75
|
+
if (/^change\.[A-Za-z0-9._-]+\.status$/.test(key)) {
|
|
76
|
+
return { type: 'change', id: changeSlug(key, context) };
|
|
77
|
+
}
|
|
78
|
+
if (/^(?:decision|adr)\b/.test(key)) {
|
|
79
|
+
const slug = changeSlug(key, context);
|
|
80
|
+
return slug ? { type: 'change', id: slug } : { type: 'project', id: projectId };
|
|
81
|
+
}
|
|
82
|
+
if (/^(?:constraint|restriction)\b/.test(key)) {
|
|
83
|
+
const session = workSession(context);
|
|
84
|
+
return session ? { type: 'work_session', id: session } : { type: 'project', id: projectId };
|
|
85
|
+
}
|
|
86
|
+
return { type: 'project', id: projectId };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function effectiveMemoryScope(event = {}) {
|
|
90
|
+
// Ledger rows written before scoped registers existed remain project-scoped until an
|
|
91
|
+
// explicit append-only rescope migration supersedes them. This preserves historic replay.
|
|
92
|
+
return normalizeMemoryScope(event.scope, { projectId: event.project_id })
|
|
93
|
+
|| { type: 'project', id: clean(event.project_id) || 'unknown-project' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function memoryScopeKey(scope) {
|
|
97
|
+
const normalized = normalizeMemoryScope(scope);
|
|
98
|
+
return normalized ? `${normalized.type}:${normalized.id}` : 'project:unknown-project';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sameMemoryScope(left, right) {
|
|
102
|
+
return memoryScopeKey(effectiveMemoryScope(left)) === memoryScopeKey(effectiveMemoryScope(right));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Project-scoped keys retain their historic public name; narrower registers are qualified. */
|
|
106
|
+
export function memoryRecordKey(event) {
|
|
107
|
+
const scope = effectiveMemoryScope(event);
|
|
108
|
+
return scope.type === 'project'
|
|
109
|
+
? String(event.memory_key)
|
|
110
|
+
: `${event.memory_key}@${memoryScopeKey(scope)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isRegisterMemoryKey(memoryKey) {
|
|
114
|
+
return REGISTER_PATTERNS.some((pattern) => pattern.test(String(memoryKey || '')));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isHumanCuratedMemoryKey(memoryKey) {
|
|
118
|
+
return /^(?:decision|adr|constraint|restriction|block|blocker)\b/.test(String(memoryKey || ''));
|
|
119
|
+
}
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
sanitizeMemoryText,
|
|
15
15
|
validateMemoryEvent,
|
|
16
16
|
} from './memory-schema.mjs';
|
|
17
|
+
import {
|
|
18
|
+
effectiveMemoryScope,
|
|
19
|
+
isRegisterMemoryKey,
|
|
20
|
+
memoryRecordKey,
|
|
21
|
+
sameMemoryScope,
|
|
22
|
+
} from './memory-scope.mjs';
|
|
17
23
|
import {
|
|
18
24
|
assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
|
|
19
25
|
VAULT_LOCK_BUSY, withVaultPathLock, writeVaultFileAtomic,
|
|
@@ -376,6 +382,7 @@ function sameCompleteCausalLineage(left, right) {
|
|
|
376
382
|
}
|
|
377
383
|
|
|
378
384
|
function comparable(left, right) {
|
|
385
|
+
if (left?.project_id !== right?.project_id || !sameMemoryScope(left, right)) return false;
|
|
379
386
|
if (sameCausalActivation(left, right)) return true;
|
|
380
387
|
const leftSupersedes = left.supersedes_event_id || left.supersedes;
|
|
381
388
|
const rightSupersedes = right.supersedes_event_id || right.supersedes;
|
|
@@ -387,7 +394,7 @@ function comparable(left, right) {
|
|
|
387
394
|
function conflictGroupKey(event) {
|
|
388
395
|
if (event.operation !== 'replace') return null;
|
|
389
396
|
if (!Number.isInteger(event.base_revision) || typeof event.base_value_hash !== 'string') return null;
|
|
390
|
-
return `${event
|
|
397
|
+
return `${memoryRecordKey(event)}\u0000${event.base_revision}\u0000${event.base_value_hash}`;
|
|
391
398
|
}
|
|
392
399
|
|
|
393
400
|
function candidateId(reason, memoryKey, eventIds) {
|
|
@@ -421,6 +428,7 @@ function blockedByCoreCandidate(event, coreValue) {
|
|
|
421
428
|
reason: 'blocked_by_core',
|
|
422
429
|
status: 'blocked_by_core',
|
|
423
430
|
memory_key: event.memory_key,
|
|
431
|
+
...(event.scope ? { scope: effectiveMemoryScope(event), record_key: memoryRecordKey(event) } : {}),
|
|
424
432
|
event_ids: [event.event_id],
|
|
425
433
|
proposed_value: event.value,
|
|
426
434
|
core_value: coreValue,
|
|
@@ -442,6 +450,10 @@ function conflictCandidate(memoryKey, events, currentEvent = null) {
|
|
|
442
450
|
candidate_id: candidateId('conflict', memoryKey, eventIds),
|
|
443
451
|
reason: 'conflict',
|
|
444
452
|
memory_key: memoryKey,
|
|
453
|
+
...((ordered[0] || currentEvent)?.scope ? {
|
|
454
|
+
scope: effectiveMemoryScope(ordered[0] || currentEvent || {}),
|
|
455
|
+
record_key: memoryRecordKey(ordered[0] || currentEvent || { memory_key: memoryKey }),
|
|
456
|
+
} : {}),
|
|
445
457
|
event_ids: eventIds,
|
|
446
458
|
values: eventIds.map((id) => byId.get(id).value),
|
|
447
459
|
base_revision: ordered[0]?.base_revision ?? currentEvent?.revision ?? 0,
|
|
@@ -459,6 +471,7 @@ function conflictReviewEvent(candidate, candidateCount = 1) {
|
|
|
459
471
|
event_id: `mem-review-${candidate.candidate_id}`,
|
|
460
472
|
project_id: source.project_id || '',
|
|
461
473
|
memory_key: candidate.memory_key,
|
|
474
|
+
scope: candidate.scope,
|
|
462
475
|
operation: 'assert',
|
|
463
476
|
value: `[revisão pendente: ${memoryKey}; candidates: ${candidateCount}; events: ${eventCount}]`,
|
|
464
477
|
authority: 'candidate',
|
|
@@ -508,7 +521,9 @@ function isCausallyOlder(event, current) {
|
|
|
508
521
|
if (sameCausalActivation(event, current)) {
|
|
509
522
|
return Number(event.turn_sequence) < Number(current.turn_sequence);
|
|
510
523
|
}
|
|
511
|
-
if (
|
|
524
|
+
if (event.canonical_session_id && event.canonical_session_id === current.canonical_session_id
|
|
525
|
+
&& sameMemoryScope(event, current)
|
|
526
|
+
&& Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
|
|
512
527
|
&& event.activation_epoch !== current.activation_epoch) {
|
|
513
528
|
return event.activation_epoch < current.activation_epoch;
|
|
514
529
|
}
|
|
@@ -518,6 +533,30 @@ function isCausallyOlder(event, current) {
|
|
|
518
533
|
&& eventEffective < currentEffective;
|
|
519
534
|
}
|
|
520
535
|
|
|
536
|
+
const AUTHORITY_RANK = Object.freeze({ candidate: 0, reported: 1, verified: 2 });
|
|
537
|
+
|
|
538
|
+
function sameRegisterLineage(left, right) {
|
|
539
|
+
return Boolean(left?.canonical_session_id)
|
|
540
|
+
&& left.canonical_session_id === right?.canonical_session_id
|
|
541
|
+
&& left.project_id === right?.project_id
|
|
542
|
+
&& sameMemoryScope(left, right);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Positive means `incoming` is a safe successor; null means human comparison is required. */
|
|
546
|
+
function registerPrecedence(incoming, current) {
|
|
547
|
+
if (!incoming?.scope || !current?.scope
|
|
548
|
+
|| !isRegisterMemoryKey(incoming?.memory_key) || !sameRegisterLineage(incoming, current)) return null;
|
|
549
|
+
const epoch = Number(incoming.activation_epoch ?? -1) - Number(current.activation_epoch ?? -1);
|
|
550
|
+
if (epoch) return epoch;
|
|
551
|
+
const turn = Number(incoming.turn_sequence ?? -1) - Number(current.turn_sequence ?? -1);
|
|
552
|
+
if (turn) return turn;
|
|
553
|
+
const authority = (AUTHORITY_RANK[incoming.authority] ?? -1) - (AUTHORITY_RANK[current.authority] ?? -1);
|
|
554
|
+
if (authority) return authority;
|
|
555
|
+
const observed = String(incoming.observed_at || '').localeCompare(String(current.observed_at || ''));
|
|
556
|
+
if (observed) return observed;
|
|
557
|
+
return String(incoming.event_id || '').localeCompare(String(current.event_id || ''));
|
|
558
|
+
}
|
|
559
|
+
|
|
521
560
|
/**
|
|
522
561
|
* Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
|
|
523
562
|
* never turns one concurrent writer into an accidental winner.
|
|
@@ -537,7 +576,21 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
537
576
|
}
|
|
538
577
|
if (!existing) unique.set(event.event_id, event);
|
|
539
578
|
}
|
|
540
|
-
const
|
|
579
|
+
const rescopeTargets = new Set(
|
|
580
|
+
[...unique.values()].flatMap((item) => [
|
|
581
|
+
item.rescopes_event_id,
|
|
582
|
+
...(Array.isArray(item.rescopes_event_ids) ? item.rescopes_event_ids : []),
|
|
583
|
+
]).filter(Boolean),
|
|
584
|
+
);
|
|
585
|
+
const projectIds = new Set([...unique.values()].map((item) => item.project_id).filter(Boolean));
|
|
586
|
+
if (projectIds.size > 1) {
|
|
587
|
+
const error = new TypeError('Memory reducer cannot compare events from different projects.');
|
|
588
|
+
error.code = 'MEMORY_PROJECT_MIXED';
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
const events = [...unique.values()]
|
|
592
|
+
.filter((item) => !rescopeTargets.has(item.event_id))
|
|
593
|
+
.sort(eventOrder);
|
|
541
594
|
const candidateDecisions = new Map();
|
|
542
595
|
for (const item of events) {
|
|
543
596
|
const decision = item.candidate_decision;
|
|
@@ -604,7 +657,8 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
604
657
|
continue;
|
|
605
658
|
}
|
|
606
659
|
|
|
607
|
-
const
|
|
660
|
+
const recordKey = memoryRecordKey(item);
|
|
661
|
+
const current = records.get(recordKey);
|
|
608
662
|
const currentSource = current?.source;
|
|
609
663
|
if (isCausallyOlder(item, currentSource)) {
|
|
610
664
|
superseded.push({ event_id: item.event_id, by_event_id: currentSource.event_id });
|
|
@@ -613,10 +667,12 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
613
667
|
|
|
614
668
|
if (item.operation === 'assert') {
|
|
615
669
|
if (current && hashMemoryValue(current.value) !== hashMemoryValue(item.value)) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
670
|
+
const precedence = registerPrecedence(item, currentSource);
|
|
671
|
+
if ((sameCausalActivation(item, currentSource)
|
|
672
|
+
&& Number(item.turn_sequence) > Number(currentSource.turn_sequence))
|
|
673
|
+
|| precedence > 0) {
|
|
674
|
+
records.set(recordKey, { value: item.value, revision: current.revision + 1, source: item });
|
|
675
|
+
tombstones.delete(recordKey);
|
|
620
676
|
superseded.push({ event_id: currentSource.event_id, by_event_id: item.event_id });
|
|
621
677
|
revision += 1;
|
|
622
678
|
appliedEventIds.push(item.event_id);
|
|
@@ -628,8 +684,8 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
628
684
|
continue;
|
|
629
685
|
}
|
|
630
686
|
if (!current) {
|
|
631
|
-
records.set(
|
|
632
|
-
tombstones.delete(
|
|
687
|
+
records.set(recordKey, { value: item.value, revision: 1, source: item });
|
|
688
|
+
tombstones.delete(recordKey);
|
|
633
689
|
revision += 1;
|
|
634
690
|
}
|
|
635
691
|
appliedEventIds.push(item.event_id);
|
|
@@ -642,8 +698,8 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
642
698
|
const byHash = new Map(oldValues.map((value) => [hashMemoryValue(value), value]));
|
|
643
699
|
additions.forEach((value) => byHash.set(hashMemoryValue(value), value));
|
|
644
700
|
const value = [...byHash.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, entry]) => entry);
|
|
645
|
-
records.set(
|
|
646
|
-
tombstones.delete(
|
|
701
|
+
records.set(recordKey, { value, revision: (current?.revision || 0) + 1, source: item });
|
|
702
|
+
tombstones.delete(recordKey);
|
|
647
703
|
revision += 1;
|
|
648
704
|
appliedEventIds.push(item.event_id);
|
|
649
705
|
continue;
|
|
@@ -664,13 +720,13 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
664
720
|
if (item.value !== null && item.value !== undefined && Array.isArray(current.value)) {
|
|
665
721
|
const removalHash = hashMemoryValue(item.value);
|
|
666
722
|
const value = current.value.filter((entry) => hashMemoryValue(entry) !== removalHash);
|
|
667
|
-
records.set(
|
|
668
|
-
tombstones.set(`${
|
|
723
|
+
records.set(recordKey, { value, revision: current.revision + 1, source: item });
|
|
724
|
+
tombstones.set(`${recordKey}:${removalHash}`, {
|
|
669
725
|
event_id: item.event_id, removed_event_id: current.source.event_id, value_hash: removalHash,
|
|
670
726
|
});
|
|
671
727
|
} else {
|
|
672
|
-
records.delete(
|
|
673
|
-
tombstones.set(
|
|
728
|
+
records.delete(recordKey);
|
|
729
|
+
tombstones.set(recordKey, {
|
|
674
730
|
event_id: item.event_id,
|
|
675
731
|
removed_event_id: current.source.event_id,
|
|
676
732
|
value_hash: hashMemoryValue(current.value),
|
|
@@ -691,8 +747,8 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
691
747
|
candidates.push(conflictCandidate(item.memory_key, current ? [currentEventFromRecord(current), item] : [item]));
|
|
692
748
|
continue;
|
|
693
749
|
}
|
|
694
|
-
records.set(
|
|
695
|
-
tombstones.delete(
|
|
750
|
+
records.set(recordKey, { value: item.value, revision: current.revision + 1, source: item });
|
|
751
|
+
tombstones.delete(recordKey);
|
|
696
752
|
const explicitlySupersededIds = new Set(
|
|
697
753
|
Array.isArray(item.supersedes)
|
|
698
754
|
? item.supersedes
|
|
@@ -732,16 +788,16 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
732
788
|
advanced = false;
|
|
733
789
|
for (const pending of deferredAsserts) {
|
|
734
790
|
if (resolvedCandidateIds.has(pending.candidate.candidate_id)) continue;
|
|
735
|
-
const current = records.get(pending.event
|
|
791
|
+
const current = records.get(memoryRecordKey(pending.event));
|
|
736
792
|
const currentSource = current?.source;
|
|
737
793
|
if (!sameCompleteCausalLineage(pending.event, currentSource)) continue;
|
|
738
794
|
if (pending.event.turn_sequence > currentSource.turn_sequence) {
|
|
739
|
-
records.set(pending.event
|
|
795
|
+
records.set(memoryRecordKey(pending.event), {
|
|
740
796
|
value: pending.event.value,
|
|
741
797
|
revision: current.revision + 1,
|
|
742
798
|
source: pending.event,
|
|
743
799
|
});
|
|
744
|
-
tombstones.delete(pending.event
|
|
800
|
+
tombstones.delete(memoryRecordKey(pending.event));
|
|
745
801
|
superseded.push({ event_id: currentSource.event_id, by_event_id: pending.event.event_id });
|
|
746
802
|
revision += 1;
|
|
747
803
|
appliedEventIds.push(pending.event.event_id);
|
|
@@ -762,7 +818,7 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
762
818
|
.map((candidate) => {
|
|
763
819
|
const pending = pendingByCandidateId.get(candidate.candidate_id);
|
|
764
820
|
if (!pending) return candidate;
|
|
765
|
-
const finalSource = currentEventFromRecord(records.get(candidate.memory_key));
|
|
821
|
+
const finalSource = currentEventFromRecord(records.get(candidate.record_key || candidate.memory_key));
|
|
766
822
|
const previousSource = candidate.events?.find(
|
|
767
823
|
(event) => event.event_id !== pending.event.event_id,
|
|
768
824
|
);
|
|
@@ -791,10 +847,16 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
791
847
|
superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
|
|
792
848
|
const eventCursor = events.at(-1)?.event_id || 'none';
|
|
793
849
|
const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
|
|
850
|
+
const ambiguousRecordKeys = new Set(
|
|
851
|
+
unresolvedCandidates.map((candidate) => candidate.record_key || candidate.memory_key),
|
|
852
|
+
);
|
|
794
853
|
const activeEvents = [
|
|
795
|
-
...Object.entries(recordObject)
|
|
854
|
+
...Object.entries(recordObject)
|
|
855
|
+
.filter(([recordKey]) => !ambiguousRecordKeys.has(recordKey))
|
|
856
|
+
.map(([recordKey, record]) => ({
|
|
796
857
|
...record.source,
|
|
797
|
-
memory_key:
|
|
858
|
+
memory_key: record.source.memory_key,
|
|
859
|
+
projection_key: recordKey,
|
|
798
860
|
operation: 'assert',
|
|
799
861
|
value: record.value,
|
|
800
862
|
})),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS document_chunks (
|
|
2
|
+
chunk_id TEXT PRIMARY KEY,
|
|
3
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
4
|
+
logical_path TEXT NOT NULL,
|
|
5
|
+
title TEXT NOT NULL DEFAULT '',
|
|
6
|
+
heading TEXT NOT NULL DEFAULT '',
|
|
7
|
+
entity_type TEXT NOT NULL DEFAULT 'document',
|
|
8
|
+
change_slug TEXT NOT NULL DEFAULT '',
|
|
9
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
10
|
+
work_session_id TEXT NOT NULL DEFAULT '',
|
|
11
|
+
authority TEXT NOT NULL DEFAULT 'candidate',
|
|
12
|
+
observed_at TEXT NOT NULL,
|
|
13
|
+
validity TEXT NOT NULL DEFAULT 'active',
|
|
14
|
+
content_hash TEXT NOT NULL,
|
|
15
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
content TEXT NOT NULL,
|
|
17
|
+
FOREIGN KEY(project_id, logical_path) REFERENCES documents(project_id, logical_path) ON DELETE CASCADE,
|
|
18
|
+
UNIQUE(project_id, logical_path, ordinal)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_project_path
|
|
22
|
+
ON document_chunks(project_id, logical_path, ordinal);
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_project_type
|
|
25
|
+
ON document_chunks(project_id, entity_type, validity, observed_at);
|
package/src/change.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
relinkChanges,
|
|
17
17
|
backfillArtifactLinks,
|
|
18
18
|
scaffoldPlaceholders,
|
|
19
|
+
isGuideCompactChange,
|
|
19
20
|
} from '../hooks/change-core.mjs';
|
|
20
21
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
21
22
|
import { buildEffectiveRequirementPackage, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
|
|
@@ -61,7 +62,9 @@ export function runChange(argv) {
|
|
|
61
62
|
// G2: link the active session into the proposta's source: (graph edge proposta->sessão).
|
|
62
63
|
let sessionRel = '';
|
|
63
64
|
try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* sem control */ }
|
|
64
|
-
const r = newChange(vaultBase, slug, {
|
|
65
|
+
const r = newChange(vaultBase, slug, {
|
|
66
|
+
dateStr: today(), simple: rest.includes('--simple'), guide: rest.includes('--guide'), sessionRel,
|
|
67
|
+
});
|
|
65
68
|
process.stdout.write(`change ${r.created ? 'created' : 'exists'}: ${r.rel} (active)\n`);
|
|
66
69
|
process.exit(0);
|
|
67
70
|
}
|
|
@@ -97,7 +100,7 @@ export function runChange(argv) {
|
|
|
97
100
|
let sessionRel = '';
|
|
98
101
|
try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* no control */ }
|
|
99
102
|
const r = continueChange(vaultBase, archivedSlug, newSlug, {
|
|
100
|
-
dateStr: today(), simple: rest.includes('--simple'), sessionRel,
|
|
103
|
+
dateStr: today(), simple: rest.includes('--simple'), guide: rest.includes('--guide'), sessionRel,
|
|
101
104
|
});
|
|
102
105
|
if (!r.ok) { process.stderr.write(`wendkeep change continue: ${r.error}\n`); process.exit(2); }
|
|
103
106
|
process.stdout.write(`change created: ${r.rel} (continues ${r.archived}; active)\n`);
|
|
@@ -287,13 +290,16 @@ export function runChange(argv) {
|
|
|
287
290
|
try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
|
|
288
291
|
const forced = rest.includes('--force') && tasks.some((t) => !t.done);
|
|
289
292
|
const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
|
|
290
|
-
|
|
293
|
+
const compactGuide = isGuideCompactChange(join(vaultBase, getLocale(vaultBase).folders.changes, slug));
|
|
294
|
+
if (trivial) process.stderr.write(compactGuide
|
|
295
|
+
? 'aviso: GUIDE compacta sem [req:]/[sensor:] — resultado permanece auditável no archive, sem ADR automático\n'
|
|
296
|
+
: 'aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
|
|
291
297
|
const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
|
|
292
298
|
if (!r.ok) {
|
|
293
299
|
process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
|
|
294
300
|
process.exit(1);
|
|
295
301
|
}
|
|
296
|
-
process.stdout.write(`archived: ${r.archivedRel}
|
|
302
|
+
process.stdout.write(`archived: ${r.archivedRel}${r.adrRel ? `; ADR: ${r.adrRel}` : '; GUIDE compacta: sem ADR'}\n`);
|
|
297
303
|
if (r.promoted && r.promoted.length) process.stdout.write(`specs promovidas: ${r.promoted.join(', ')}\n`);
|
|
298
304
|
if (r.specWarnings && r.specWarnings.length) for (const w of r.specWarnings) process.stderr.write(` aviso spec: ${w}\n`);
|
|
299
305
|
process.exit(0);
|