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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.en.md +37 -16
  3. package/README.md +37 -16
  4. package/docs/en/commands/changes-and-verification.md +10 -5
  5. package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
  6. package/docs/en/commands/memory.md +16 -1
  7. package/docs/en/commands/observer.md +8 -1
  8. package/docs/en/commands/operating-profiles.md +28 -3
  9. package/docs/pt-BR/commands/changes-and-verification.md +10 -5
  10. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
  11. package/docs/pt-BR/commands/memory.md +16 -1
  12. package/docs/pt-BR/commands/observer.md +7 -1
  13. package/docs/pt-BR/commands/operating-profiles.md +28 -3
  14. package/hooks/brain-core.mjs +2 -0
  15. package/hooks/brain-inject.mjs +6 -6
  16. package/hooks/brain-recall.mjs +5 -1
  17. package/hooks/change-context.mjs +11 -0
  18. package/hooks/change-core.mjs +53 -21
  19. package/hooks/change-warn.mjs +2 -0
  20. package/hooks/evidence-context.mjs +41 -0
  21. package/hooks/evidence-recall.mjs +1 -0
  22. package/hooks/harness-doctor.mjs +13 -5
  23. package/hooks/memory-scope.mjs +1 -0
  24. package/hooks/vault-health.mjs +2 -2
  25. package/package.json +2 -2
  26. package/packages/cli/src/index.mjs +13 -3
  27. package/packages/integrations/src/host-hooks.mjs +1 -0
  28. package/packages/vault/src/evidence-recall.mjs +343 -0
  29. package/packages/vault/src/index.mjs +2 -0
  30. package/packages/vault/src/memory-handoff.mjs +58 -3
  31. package/packages/vault/src/memory-schema.mjs +12 -2
  32. package/packages/vault/src/memory-scope.mjs +119 -0
  33. package/packages/vault/src/memory-store.mjs +86 -24
  34. package/schema/observer/004-evidence-recall.sql +25 -0
  35. package/src/change.mjs +10 -4
  36. package/src/delivery.mjs +303 -0
  37. package/src/doctor.mjs +47 -10
  38. package/src/memory.mjs +95 -2
  39. package/src/observer-sql-store.mjs +141 -5
  40. package/src/release-provenance.mjs +47 -0
  41. package/src/skills-seed.mjs +25 -9
  42. package/src/sync-defs.mjs +5 -2
  43. package/src/sync.mjs +2 -2
  44. package/src/taxonomy.mjs +4 -0
  45. 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 line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
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.memory_key}\u0000${event.base_revision}\u0000${event.base_value_hash}`;
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 (Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
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 events = [...unique.values()].sort(eventOrder);
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 current = records.get(item.memory_key);
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
- if (sameCausalActivation(item, currentSource)
617
- && Number(item.turn_sequence) > Number(currentSource.turn_sequence)) {
618
- records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
619
- tombstones.delete(item.memory_key);
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(item.memory_key, { value: item.value, revision: 1, source: item });
632
- tombstones.delete(item.memory_key);
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(item.memory_key, { value, revision: (current?.revision || 0) + 1, source: item });
646
- tombstones.delete(item.memory_key);
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(item.memory_key, { value, revision: current.revision + 1, source: item });
668
- tombstones.set(`${item.memory_key}:${removalHash}`, {
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(item.memory_key);
673
- tombstones.set(item.memory_key, {
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(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
695
- tombstones.delete(item.memory_key);
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.memory_key);
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.memory_key, {
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.memory_key);
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).map(([memoryKey, record]) => ({
854
+ ...Object.entries(recordObject)
855
+ .filter(([recordKey]) => !ambiguousRecordKeys.has(recordKey))
856
+ .map(([recordKey, record]) => ({
796
857
  ...record.source,
797
- memory_key: memoryKey,
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, { dateStr: today(), simple: rest.includes('--simple'), sessionRel });
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
- if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
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}; ADR: ${r.adrRel}\n`);
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);