brainclaw 1.24.0 → 1.25.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Bounded, local TypeScript/JavaScript resolver configuration.
3
+ *
4
+ * Only root tsconfig/jsconfig, local extends, baseUrl and paths are supported.
5
+ * Package extends and node_modules are deliberately never read. Any malformed,
6
+ * escaping, cyclic, or ambiguous configuration is invalid so callers abstain.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ const MAX_EXTENDS_DEPTH = 8;
12
+ const CONFIG_FILENAMES = ['tsconfig.json', 'jsconfig.json'];
13
+ function isObject(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ function toPosix(value) {
17
+ return value.replace(/\\/g, '/');
18
+ }
19
+ function configFingerprint(parts) {
20
+ return `sha256:${crypto.createHash('sha256').update(parts.join('\n'), 'utf8').digest('hex')}`;
21
+ }
22
+ /** Strip JSONC comments without changing comment-looking text inside strings. */
23
+ function stripJsonComments(source) {
24
+ let out = '';
25
+ let inString = false;
26
+ let escaped = false;
27
+ for (let i = 0; i < source.length; i++) {
28
+ const ch = source[i];
29
+ const next = source[i + 1];
30
+ if (inString) {
31
+ out += ch;
32
+ if (escaped)
33
+ escaped = false;
34
+ else if (ch === '\\')
35
+ escaped = true;
36
+ else if (ch === '"')
37
+ inString = false;
38
+ continue;
39
+ }
40
+ if (ch === '"') {
41
+ inString = true;
42
+ out += ch;
43
+ continue;
44
+ }
45
+ if (ch === '/' && next === '/') {
46
+ i++;
47
+ while (i + 1 < source.length && source[i + 1] !== '\n' && source[i + 1] !== '\r')
48
+ i++;
49
+ continue;
50
+ }
51
+ if (ch === '/' && next === '*') {
52
+ const close = source.indexOf('*/', i + 2);
53
+ if (close < 0)
54
+ return null;
55
+ i = close + 1;
56
+ continue;
57
+ }
58
+ out += ch;
59
+ }
60
+ return inString ? null : out;
61
+ }
62
+ /** JSONC permits trailing commas; remove them only outside quoted strings. */
63
+ function stripTrailingCommas(source) {
64
+ let out = '';
65
+ let inString = false;
66
+ let escaped = false;
67
+ for (let i = 0; i < source.length; i++) {
68
+ const ch = source[i];
69
+ if (inString) {
70
+ out += ch;
71
+ if (escaped)
72
+ escaped = false;
73
+ else if (ch === '\\')
74
+ escaped = true;
75
+ else if (ch === '"')
76
+ inString = false;
77
+ continue;
78
+ }
79
+ if (ch === '"') {
80
+ inString = true;
81
+ out += ch;
82
+ continue;
83
+ }
84
+ if (ch === ',') {
85
+ let next = i + 1;
86
+ while (next < source.length && /\s/.test(source[next]))
87
+ next++;
88
+ if (source[next] === '}' || source[next] === ']')
89
+ continue;
90
+ }
91
+ out += ch;
92
+ }
93
+ return out;
94
+ }
95
+ function parseJsonc(source) {
96
+ const withoutComments = stripJsonComments(source);
97
+ if (withoutComments === null)
98
+ return null;
99
+ try {
100
+ const parsed = JSON.parse(stripTrailingCommas(withoutComments));
101
+ return isObject(parsed) ? parsed : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function isWithin(root, absolute) {
108
+ const rel = path.relative(root, absolute);
109
+ return rel === '' || (!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${path.sep}`));
110
+ }
111
+ function projectRelative(root, absolute) {
112
+ return isWithin(root, absolute) ? toPosix(path.relative(root, absolute)) : null;
113
+ }
114
+ /** Resolve a config-local directory, refusing absolute and project-escaping values. */
115
+ function localDirectory(root, configDir, value) {
116
+ if (path.isAbsolute(value))
117
+ return null;
118
+ return projectRelative(root, path.resolve(configDir, value));
119
+ }
120
+ function starCount(value) {
121
+ return [...value].filter((c) => c === '*').length;
122
+ }
123
+ function parsePaths(raw, root, configDir, baseUrl) {
124
+ if (!isObject(raw))
125
+ return null;
126
+ const targetDir = baseUrl === null ? configDir : path.resolve(root, baseUrl);
127
+ const mappings = [];
128
+ for (const pattern of Object.keys(raw).sort()) {
129
+ const values = raw[pattern];
130
+ const stars = starCount(pattern);
131
+ if (pattern.length === 0 || stars > 1 || !Array.isArray(values) || values.length === 0)
132
+ return null;
133
+ const targets = [];
134
+ for (const value of values) {
135
+ if (typeof value !== 'string' || starCount(value) > 1 || (stars === 0 && starCount(value) !== 0))
136
+ return null;
137
+ const target = localDirectory(root, targetDir, value);
138
+ if (target === null)
139
+ return null;
140
+ targets.push(target);
141
+ }
142
+ mappings.push({ pattern, targets });
143
+ }
144
+ return mappings;
145
+ }
146
+ function localExtendsPath(root, configDir, value) {
147
+ // Bare values name packages; never follow them, even if node_modules is present.
148
+ if (!value.startsWith('./') && !value.startsWith('../'))
149
+ return null;
150
+ const candidate = path.resolve(configDir, value);
151
+ if (!isWithin(root, candidate))
152
+ return null;
153
+ const jsonPath = path.extname(candidate) ? candidate : `${candidate}.json`;
154
+ return isWithin(root, jsonPath) ? jsonPath : null;
155
+ }
156
+ function readConfig(filename, depth, state) {
157
+ const empty = { baseUrl: null, paths: [] };
158
+ if (depth > MAX_EXTENDS_DEPTH || state.seen.has(filename) || !isWithin(state.root, filename)) {
159
+ return { valid: false, options: empty };
160
+ }
161
+ state.seen.add(filename);
162
+ let source;
163
+ try {
164
+ source = fs.readFileSync(filename, 'utf8');
165
+ }
166
+ catch {
167
+ return { valid: false, options: empty };
168
+ }
169
+ state.fingerprintParts.push(`${projectRelative(state.root, filename) ?? filename}\u0000${source}`);
170
+ const json = parseJsonc(source);
171
+ if (!json)
172
+ return { valid: false, options: empty };
173
+ const configDir = path.dirname(filename);
174
+ let inherited = empty;
175
+ if (json.extends !== undefined) {
176
+ if (typeof json.extends !== 'string')
177
+ return { valid: false, options: empty };
178
+ const parent = localExtendsPath(state.root, configDir, json.extends);
179
+ if (!parent)
180
+ return { valid: false, options: empty };
181
+ const parentResult = readConfig(parent, depth + 1, state);
182
+ if (!parentResult.valid)
183
+ return { valid: false, options: empty };
184
+ inherited = parentResult.options;
185
+ }
186
+ if (json.compilerOptions !== undefined && !isObject(json.compilerOptions))
187
+ return { valid: false, options: empty };
188
+ const options = json.compilerOptions ?? {};
189
+ let baseUrl = inherited.baseUrl;
190
+ if (options.baseUrl !== undefined) {
191
+ if (typeof options.baseUrl !== 'string')
192
+ return { valid: false, options: empty };
193
+ baseUrl = localDirectory(state.root, configDir, options.baseUrl);
194
+ if (baseUrl === null)
195
+ return { valid: false, options: empty };
196
+ }
197
+ let paths = inherited.paths;
198
+ if (options.paths !== undefined) {
199
+ const parsedPaths = parsePaths(options.paths, state.root, configDir, baseUrl);
200
+ if (!parsedPaths)
201
+ return { valid: false, options: empty };
202
+ paths = parsedPaths;
203
+ }
204
+ return { valid: true, options: { baseUrl, paths } };
205
+ }
206
+ /**
207
+ * Read exactly one root configuration. Two root configs are intentionally
208
+ * ambiguous: TypeScript tooling can choose based on invocation, Code Map cannot.
209
+ */
210
+ export function loadTypeScriptResolutionConfig(projectRoot) {
211
+ const root = path.resolve(projectRoot);
212
+ const found = CONFIG_FILENAMES.map((name) => path.join(root, name)).filter((filename) => fs.existsSync(filename));
213
+ if (found.length === 0) {
214
+ return { kind: 'typescript-resolution-config', fingerprint: configFingerprint([]), valid: true, baseUrl: null, paths: [] };
215
+ }
216
+ if (found.length > 1) {
217
+ const parts = found.map((filename) => {
218
+ try {
219
+ return `${path.basename(filename)}\u0000${fs.readFileSync(filename, 'utf8')}`;
220
+ }
221
+ catch {
222
+ return `${path.basename(filename)}\u0000<unreadable>`;
223
+ }
224
+ });
225
+ return { kind: 'typescript-resolution-config', fingerprint: configFingerprint(parts), valid: false, baseUrl: null, paths: [] };
226
+ }
227
+ const state = { root, seen: new Set(), fingerprintParts: [] };
228
+ const result = readConfig(found[0], 0, state);
229
+ return {
230
+ kind: 'typescript-resolution-config',
231
+ fingerprint: configFingerprint(state.fingerprintParts),
232
+ valid: result.valid,
233
+ baseUrl: result.valid ? result.options.baseUrl : null,
234
+ paths: result.valid ? result.options.paths : [],
235
+ };
236
+ }
237
+ export function isTypeScriptResolutionConfig(value) {
238
+ return !!value && typeof value === 'object' && value.kind === 'typescript-resolution-config';
239
+ }
240
+ function matchPattern(pattern, specifier) {
241
+ const star = pattern.indexOf('*');
242
+ if (star < 0)
243
+ return pattern === specifier ? '' : null;
244
+ const prefix = pattern.slice(0, star);
245
+ const suffix = pattern.slice(star + 1);
246
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix))
247
+ return null;
248
+ return specifier.slice(prefix.length, specifier.length - suffix.length);
249
+ }
250
+ /** Map a bare specifier to project-relative candidate bases, or no candidates. */
251
+ export function typeScriptSpecifierBases(specifier, config) {
252
+ if (!config?.valid)
253
+ return [];
254
+ const matches = config.paths
255
+ .map((mapping) => ({ mapping, wildcard: matchPattern(mapping.pattern, specifier) }))
256
+ .filter((match) => match.wildcard !== null);
257
+ // Overlapping paths patterns are intentionally not ranked: ambiguity abstains.
258
+ if (matches.length > 1)
259
+ return [];
260
+ if (matches.length === 1) {
261
+ const { mapping, wildcard } = matches[0];
262
+ // `parsePaths` garantit AU PLUS une `*` par cible (starCount > 1 ⇒ config invalide,
263
+ // donc abstention totale) : split/join et replace-première-occurrence sont ici
264
+ // équivalents. split/join est préféré parce qu'il reste correct même si cet
265
+ // invariant bougeait — et il est lisible par l'analyse statique (js/incomplete-
266
+ // sanitization signalait le replace sans pouvoir voir l'invariant amont).
267
+ return mapping.targets.map((target) => target.split('*').join(wildcard));
268
+ }
269
+ return config.baseUrl === null ? [] : [path.posix.join(config.baseUrl, specifier)];
270
+ }
271
+ //# sourceMappingURL=config.js.map
@@ -26,6 +26,7 @@ import path from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { grammarHash, grammarName, loadGrammar } from '../../wasm-loader.js';
28
28
  import { extractWithQueries } from '../query-runtime.js';
29
+ import { isTypeScriptResolutionConfig, typeScriptSpecifierBases, } from './config.js';
29
30
  const HERE = path.dirname(fileURLToPath(import.meta.url));
30
31
  /** Resolve a vendored `.scm` next to this module (dist) or from the source tree. */
31
32
  function readScm(basename) {
@@ -173,10 +174,7 @@ const JS_LIKE_EXTS = new Set(['.js', '.jsx', '.mjs', '.cjs']);
173
174
  * extension, then `<candidate>/index.<ext>`. Return the FIRST that is an indexed
174
175
  * file. Bare/external specifiers (`react`, `@scope/x`) → no resolution (no edge).
175
176
  */
176
- function resolveTsImport(spec, fromPath, ctx) {
177
- if (!spec.startsWith('./') && !spec.startsWith('../'))
178
- return null; // external/bare → no edge
179
- const base = path.posix.join(path.posix.dirname(fromPath), spec); // normalized project-relative
177
+ function resolveTsCandidate(base, ctx) {
180
178
  const ext = path.posix.extname(base);
181
179
  const candidates = [];
182
180
  if (ext) {
@@ -198,6 +196,24 @@ function resolveTsImport(spec, fromPath, ctx) {
198
196
  }
199
197
  return null;
200
198
  }
199
+ function resolveTsImport(spec, fromPath, ctx) {
200
+ if (spec.startsWith('./') || spec.startsWith('../')) {
201
+ return resolveTsCandidate(path.posix.join(path.posix.dirname(fromPath), spec), ctx);
202
+ }
203
+ const config = isTypeScriptResolutionConfig(ctx.resolverConfig) ? ctx.resolverConfig : undefined;
204
+ const candidates = typeScriptSpecifierBases(spec, config);
205
+ if (candidates.length === 0)
206
+ return null; // external, invalid, or ambiguous config
207
+ const resolved = new Set();
208
+ for (const candidate of candidates) {
209
+ const target = resolveTsCandidate(candidate, ctx);
210
+ if (target)
211
+ resolved.add(target);
212
+ }
213
+ // Do not adopt TypeScript's fallback preference when config candidates produce
214
+ // different indexed files: Code Map is deliberately soundness-first.
215
+ return resolved.size === 1 ? [...resolved][0] : null;
216
+ }
201
217
  function isDefSourceNode(v) {
202
218
  return (typeof v === 'object' &&
203
219
  v !== null &&
@@ -277,6 +277,58 @@ export function scoreEntry(entry, query) {
277
277
  score *= 0.4;
278
278
  return score;
279
279
  }
280
+ /**
281
+ * Number of distinct indexed files that import a symbol (or its defining file).
282
+ *
283
+ * This is deliberately a tie-breaker, not a broad popularity boost: a precise
284
+ * textual match must always beat a merely popular substring match. The symbol
285
+ * and file reverse indexes can name the same importer, so count their UNION to
286
+ * avoid giving named imports a double bonus over namespace/default imports.
287
+ */
288
+ export function importCentrality(entry, resolutionIndex) {
289
+ if (!resolutionIndex)
290
+ return 0;
291
+ const importers = new Set();
292
+ for (const dep of resolutionIndex.dependents_by_symbol[entry.node_id] ?? [])
293
+ importers.add(dep.path);
294
+ for (const dep of resolutionIndex.dependents_by_file[entry.path] ?? [])
295
+ importers.add(dep.path);
296
+ return importers.size;
297
+ }
298
+ /** A test-only helper is a weak orientation point when a file has real exports too. */
299
+ function isTestHelperSymbol(name) {
300
+ const normalized = normIdent(name);
301
+ return (name.startsWith('_') ||
302
+ /(?:^|_)(?:test|tests|mock|stub|fixture|reset)(?:_|$)/i.test(name) ||
303
+ normalized.includes('fortest') ||
304
+ normalized.includes('fortests'));
305
+ }
306
+ /**
307
+ * Pick one meaningful definition per file for the reading-list explanation.
308
+ * A path brief resolves all symbols in that file; repeatedly adding +12 for
309
+ * each one made the result's reason depend on index order, which could select
310
+ * an internal `__reset…ForTests` helper ahead of the public entry point.
311
+ */
312
+ function representativeDefinitions(defining) {
313
+ const byPath = new Map();
314
+ const relevance = (entry) => {
315
+ let score = entry.score_hint * 100; // public definitions before internals
316
+ if (entry.subtype === 'component' || entry.subtype === 'hook')
317
+ score += 2;
318
+ if (isTestHelperSymbol(entry.name))
319
+ score -= 50;
320
+ return score;
321
+ };
322
+ for (const entry of defining) {
323
+ const previous = byPath.get(entry.path);
324
+ if (!previous ||
325
+ relevance(entry) > relevance(previous) ||
326
+ (relevance(entry) === relevance(previous) && entry.name.localeCompare(previous.name) < 0)) {
327
+ byPath.set(entry.path, entry);
328
+ }
329
+ }
330
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
331
+ }
280
332
  function resolveRoot(ctx) {
281
333
  if (ctx.projectRoot)
282
334
  return ctx.projectRoot;
@@ -325,6 +377,7 @@ export function findInStore(query, ctx, checker, acc) {
325
377
  const root = resolveRoot(ctx);
326
378
  const maxBytes = maxParseBytes(ctx);
327
379
  const candidates = gatherSymbolEntries(index, query);
380
+ const resolutionIndex = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
328
381
  const ranked = [];
329
382
  for (const entry of candidates) {
330
383
  // §6.1 — lazy validate before serving as confident.
@@ -332,17 +385,23 @@ export function findInStore(query, ctx, checker, acc) {
332
385
  if (!confident)
333
386
  continue;
334
387
  ranked.push({
335
- node_id: entry.node_id,
336
- name: entry.name,
337
- path: entry.path,
338
- file_id: entry.file_id,
339
- kind: entry.kind,
340
- subtype: entry.subtype ?? null,
341
- score: scoreEntry(entry, query),
388
+ match: {
389
+ node_id: entry.node_id,
390
+ name: entry.name,
391
+ path: entry.path,
392
+ file_id: entry.file_id,
393
+ kind: entry.kind,
394
+ subtype: entry.subtype ?? null,
395
+ score: scoreEntry(entry, query),
396
+ },
397
+ centrality: importCentrality(entry, resolutionIndex),
342
398
  });
343
399
  }
344
- ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
345
- return { matches: ranked, base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
400
+ ranked.sort((a, b) => b.match.score - a.match.score ||
401
+ b.centrality - a.centrality ||
402
+ a.match.path.localeCompare(b.match.path) ||
403
+ a.match.name.localeCompare(b.match.name));
404
+ return { matches: ranked.map(({ match }) => match), base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
346
405
  }
347
406
  export function find(query, limit, ctx) {
348
407
  const checker = makeLazyChecker();
@@ -436,7 +495,7 @@ function rankFiles(defining, forwardRows, reverseRows, symbolsIndex, importsInde
436
495
  };
437
496
  // 1. defining files — strongest, non-graph.
438
497
  const definingDirs = new Set();
439
- for (const entry of defining) {
498
+ for (const entry of representativeDefinitions(defining)) {
440
499
  const subtypeNote = entry.subtype ? ` (${entry.subtype})` : '';
441
500
  bump(entry.path, entry.file_id, `defines matching symbol ${entry.name}${subtypeNote}`, 12, false);
442
501
  definingDirs.add(path.posix.dirname(entry.path.replace(/\\/g, '/')));
@@ -604,13 +663,17 @@ function looksLikePathTarget(target) {
604
663
  /** Find files whose path matches the target directly (path-target briefs). */
605
664
  function filesMatchingPath(symbolsIndex, target) {
606
665
  const norm = target.replace(/\\/g, '/');
607
- const seenPaths = new Set();
666
+ // Keep every symbol in the matching file. `rankFiles()` selects the one
667
+ // meaningful representative for the reason; deduping by path here used to
668
+ // retain whichever token bucket happened to be visited first (often an
669
+ // internal `__reset…ForTests` helper) and discarded the public entry point.
670
+ const seenNodeIds = new Set();
608
671
  const out = [];
609
672
  for (const bucket of Object.values(symbolsIndex.entries)) {
610
673
  for (const entry of bucket) {
611
674
  const p = entry.path.replace(/\\/g, '/');
612
- if ((p === norm || p.endsWith(`/${norm}`) || p.includes(norm)) && !seenPaths.has(entry.path)) {
613
- seenPaths.add(entry.path);
675
+ if ((p === norm || p.endsWith(`/${norm}`) || p.includes(norm)) && !seenNodeIds.has(entry.node_id)) {
676
+ seenNodeIds.add(entry.node_id);
614
677
  out.push(entry);
615
678
  }
616
679
  }
Binary file
@@ -77,6 +77,7 @@ export async function resolveProjectImports(input) {
77
77
  const ctx = {
78
78
  fileExists: (rel) => fileLang.has(toPosix(rel)),
79
79
  langOfFile: (rel) => fileLang.get(toPosix(rel)),
80
+ resolverConfig: input.resolverConfig,
80
81
  };
81
82
  // Target-file lookups for B: path -> shard, and a memoized importable index
82
83
  // (name -> importable symbols) per target file (built lazily, reused across importers).
@@ -243,6 +243,19 @@ export const ImportsIndexSchema = z.object({
243
243
  entries: z.record(z.string(), z.array(ImportIndexEntrySchema)).default({}),
244
244
  });
245
245
  // --- resolution index (P1d) — reverse dependency maps over the P1c graph ---
246
+ /**
247
+ * One concrete resolved edge which made an importer depend on a target. Kept
248
+ * alongside the compact aggregate fields on {@link DependencyIndexEntrySchema}
249
+ * so impact analysis can explain every relation without re-reading all shards.
250
+ */
251
+ export const DependencyReasonSchema = z.object({
252
+ kind: z.enum(['resolves_to', 'imports_symbol']),
253
+ module: z.string().optional(),
254
+ imported: z.array(z.string()).default([]),
255
+ confidence: z.number().optional(),
256
+ /** Source line of the import edge when the extractor supplied one. */
257
+ source_line: z.number().int().nullable().optional(),
258
+ });
246
259
  /**
247
260
  * One DEPENDENT of a target (file or symbol): the importing file + enough metadata
248
261
  * to lazy-validate it (file_id) and explain WHY it appears (module specifier the
@@ -259,6 +272,8 @@ export const DependencyIndexEntrySchema = z.object({
259
272
  imported: z.array(z.string()).default([]),
260
273
  /** Resolution edge confidence (inherited from the A file resolution). */
261
274
  confidence: z.number().optional(),
275
+ /** Every resolved edge merged into this importer/target row, source ordered. */
276
+ reasons: z.array(DependencyReasonSchema).default([]),
262
277
  });
263
278
  /**
264
279
  * Reverse dependency index (P1d): "who imports this target". Built at refresh from
@@ -10,8 +10,14 @@ import { verifyInboundBatch } from './federation-inbound.js';
10
10
  import { loadEpochPrivateKey } from './federation-keyring.js';
11
11
  import { localIdForOpaque, rememberOpaqueId } from './federation-opaque-ids.js';
12
12
  import { addStep, createPlan, updatePlan, updateStep } from './operations/plan.js';
13
+ import { createConstraint, createDecision, createTrap } from './operations/memory-write.js';
14
+ import { updateMemoryItem } from './operations/memory-mutation.js';
15
+ import { createSequence, updateSequence } from './sequence.js';
16
+ import { generateRuntimeNoteId, listRuntimeNotes, saveRuntimeNote } from './runtime.js';
17
+ import { HandoffSchema } from './schema.js';
18
+ import { generateIdWithLabel, nowISO } from './ids.js';
19
+ import { mutateState } from './state.js';
13
20
  import { memoryDir, writeFileAtomic } from './io.js';
14
- import { nowISO } from './ids.js';
15
21
  import { loadConnectionState, recordRevision, saveConnectionState } from './federation-state.js';
16
22
  const INBOUND_SCHEMA = 'brainclaw.federation-inbound-pull/v1';
17
23
  const INBOUND_FILE = 'inbound-pull.json';
@@ -164,6 +170,55 @@ function tagsOf(content) {
164
170
  function priorityOf(value) {
165
171
  return value === 'low' || value === 'medium' || value === 'high' || value === 'critical' ? value : undefined;
166
172
  }
173
+ function statusOf(value, accepted) {
174
+ return typeof value === 'string' && accepted.includes(value) ? value : undefined;
175
+ }
176
+ function authorOf(accepted) {
177
+ // L'identité de signature a été vérifiée par verifyInboundBatch contre le roster :
178
+ // elle est sûre à conserver comme provenance locale, sans prétendre connaître un nom.
179
+ return `federation:${accepted.envelope.origin_sig.key_id}`;
180
+ }
181
+ function textAndTagsPatch(text, tags) {
182
+ return { text, ...(tags ? { tags } : {}) };
183
+ }
184
+ /**
185
+ * Handoff has no standalone create operation yet. This goes through mutateState, the same
186
+ * canonical mutation pipeline used by its lifecycle operations: it never writes an entity
187
+ * JSON file directly. The remote projection does not carry `from`/`to`, so their local
188
+ * receiver values deliberately describe the federation hop rather than invent source data.
189
+ */
190
+ function saveFederatedHandoff(input, cwd) {
191
+ if (input.id) {
192
+ mutateState((state) => {
193
+ const current = state.open_handoffs.find((handoff) => handoff.id === input.id);
194
+ if (!current)
195
+ throw new Error(`handoff with id '${input.id}' not found locally despite its opaque mapping`);
196
+ const next = HandoffSchema.parse({
197
+ ...current,
198
+ text: input.text,
199
+ tags: input.tags ?? current.tags,
200
+ status: input.status ?? current.status,
201
+ });
202
+ Object.assign(current, next);
203
+ }, cwd);
204
+ return input.id;
205
+ }
206
+ const { id, short_label } = generateIdWithLabel('open_handoffs', cwd);
207
+ mutateState((state) => {
208
+ state.open_handoffs.push(HandoffSchema.parse({
209
+ id,
210
+ short_label,
211
+ from: input.author,
212
+ to: 'local',
213
+ text: input.text,
214
+ created_at: nowISO(),
215
+ author: input.author,
216
+ status: input.status ?? 'open',
217
+ tags: input.tags ?? [],
218
+ }));
219
+ }, cwd);
220
+ return id;
221
+ }
167
222
  class DeferredMaterialization extends Error {
168
223
  }
169
224
  /**
@@ -213,8 +268,101 @@ function materialize(accepted, state, cwd) {
213
268
  rememberOpaqueId(state.cloud_project_id, created.stepId, opaque, cwd);
214
269
  return;
215
270
  }
216
- // Les familles sans mutation canonique correspondante sont différées. Les accepter dans
217
- // high_water les ferait disparaître du feed sans jamais atteindre le magasin local.
271
+ const author = authorOf(accepted);
272
+ const text = content['text'];
273
+ if (accepted.kind === 'decision') {
274
+ if (existing) {
275
+ updateMemoryItem({ id: existing, type: 'decision', patch: textAndTagsPatch(text, tags) }, cwd);
276
+ return;
277
+ }
278
+ const created = createDecision({ text, author, tags }, cwd);
279
+ rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
280
+ return;
281
+ }
282
+ if (accepted.kind === 'constraint') {
283
+ const status = statusOf(accepted.envelope.meta.status.object, ['active', 'resolved', 'expired']);
284
+ if (existing) {
285
+ updateMemoryItem({
286
+ id: existing,
287
+ type: 'constraint',
288
+ patch: { ...textAndTagsPatch(text, tags), ...(status ? { status } : {}) },
289
+ }, cwd);
290
+ return;
291
+ }
292
+ const created = createConstraint({ text, author, tags }, cwd);
293
+ // createConstraint correctly owns ID/provenance creation; its lifecycle starts at active,
294
+ // so apply a projected terminal state through the same mutation path afterwards.
295
+ if (status && status !== 'active') {
296
+ updateMemoryItem({ id: created.id, type: 'constraint', patch: { status } }, cwd);
297
+ }
298
+ rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
299
+ return;
300
+ }
301
+ if (accepted.kind === 'trap') {
302
+ const status = statusOf(accepted.envelope.meta.status.object, ['active', 'resolved', 'expired']);
303
+ const severity = accepted.envelope.meta.priority === 'low' || accepted.envelope.meta.priority === 'medium'
304
+ ? accepted.envelope.meta.priority
305
+ : accepted.envelope.meta.priority === 'high' || accepted.envelope.meta.priority === 'critical'
306
+ ? 'high'
307
+ : undefined;
308
+ if (existing) {
309
+ updateMemoryItem({
310
+ id: existing,
311
+ type: 'trap',
312
+ patch: {
313
+ ...textAndTagsPatch(text, tags),
314
+ ...(status ? { status } : {}),
315
+ ...(severity ? { severity } : {}),
316
+ },
317
+ }, cwd);
318
+ return;
319
+ }
320
+ const created = createTrap({ text, author, tags, status, severity }, cwd);
321
+ rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
322
+ return;
323
+ }
324
+ if (accepted.kind === 'handoff') {
325
+ const status = statusOf(accepted.envelope.meta.status.object, ['open', 'accepted', 'closed']);
326
+ const id = saveFederatedHandoff({ id: existing, text, tags, status, author }, cwd);
327
+ if (!existing)
328
+ rememberOpaqueId(state.cloud_project_id, id, opaque, cwd);
329
+ return;
330
+ }
331
+ if (accepted.kind === 'sequence') {
332
+ const status = statusOf(accepted.envelope.meta.status.object, ['draft', 'active', 'archived']);
333
+ if (existing) {
334
+ updateSequence({ id: existing, name: text, tags, status }, cwd);
335
+ return;
336
+ }
337
+ const created = createSequence({ name: text, author, tags, status }, cwd);
338
+ rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
339
+ return;
340
+ }
341
+ if (accepted.kind === 'runtime_note') {
342
+ if (existing) {
343
+ const current = listRuntimeNotes({ visibility: 'all', includeAllHosts: true }, cwd)
344
+ .find((note) => note.id === existing);
345
+ if (!current)
346
+ throw new Error(`runtime_note with id '${existing}' not found locally despite its opaque mapping`);
347
+ saveRuntimeNote({ ...current, text, tags: tags ?? current.tags }, cwd);
348
+ return;
349
+ }
350
+ const id = generateRuntimeNoteId();
351
+ saveRuntimeNote({
352
+ id,
353
+ agent: 'federation',
354
+ agent_id: accepted.envelope.origin_sig.key_id,
355
+ text,
356
+ created_at: nowISO(),
357
+ tags: tags ?? [],
358
+ visibility: 'shared',
359
+ note_type: 'observation',
360
+ }, cwd);
361
+ rememberOpaqueId(state.cloud_project_id, id, opaque, cwd);
362
+ return;
363
+ }
364
+ // Les familles hors projection restent dans le journal : les accepter dans high_water
365
+ // les ferait disparaître du feed sans jamais atteindre le magasin local.
218
366
  throw new DeferredMaterialization(`kind '${accepted.kind}' sans mutation canonique de réception.`);
219
367
  }
220
368
  /**