brainclaw 1.24.0 → 1.26.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 (46) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-code-map.js +9 -2
  3. package/dist/commands/code-map.js +120 -6
  4. package/dist/commands/mcp-catalog.js +46 -0
  5. package/dist/commands/mcp.js +58 -6
  6. package/dist/commands/session-start.js +84 -13
  7. package/dist/core/bootstrap.js +28 -4
  8. package/dist/core/code-map/aggregate.js +36 -31
  9. package/dist/core/code-map/backend.js +162 -5
  10. package/dist/core/code-map/core.js +1 -0
  11. package/dist/core/code-map/export.js +212 -0
  12. package/dist/core/code-map/finalizer.js +57 -2
  13. package/dist/core/code-map/freshness.js +81 -15
  14. package/dist/core/code-map/impact.js +409 -0
  15. package/dist/core/code-map/indexes.js +64 -3
  16. package/dist/core/code-map/lang/python/index.js +4 -2
  17. package/dist/core/code-map/lang/query-runtime.js +2 -0
  18. package/dist/core/code-map/lang/typescript/config.js +271 -0
  19. package/dist/core/code-map/lang/typescript/index.js +24 -6
  20. package/dist/core/code-map/lang/usages.js +333 -0
  21. package/dist/core/code-map/memory-reader.js +15 -0
  22. package/dist/core/code-map/query.js +285 -71
  23. package/dist/core/code-map/refresh.js +0 -0
  24. package/dist/core/code-map/resolve.js +28 -2
  25. package/dist/core/code-map/store.js +1 -0
  26. package/dist/core/code-map/types.js +70 -9
  27. package/dist/core/code-map/vocabulary.js +6 -0
  28. package/dist/core/code-map/work-section.js +12 -14
  29. package/dist/core/context-diff.js +17 -3
  30. package/dist/core/entity-operations.js +14 -2
  31. package/dist/core/federation-pull.js +151 -3
  32. package/dist/core/federation-push.js +16 -3
  33. package/dist/core/hint-aging.js +4 -1
  34. package/dist/core/identity.js +69 -17
  35. package/dist/core/io.js +27 -0
  36. package/dist/core/project-discovery.js +7 -1
  37. package/dist/core/protocol-tool-policy.js +3 -0
  38. package/dist/core/runtime.js +23 -0
  39. package/dist/core/worktree.js +89 -2
  40. package/dist/facts.js +15 -12
  41. package/dist/facts.json +14 -11
  42. package/docs/cli.md +8 -0
  43. package/docs/code-map.md +60 -28
  44. package/docs/integrations/mcp.md +5 -2
  45. package/docs/mcp-schema-changelog.md +11 -1
  46. package/package.json +1 -1
@@ -110,6 +110,30 @@ export const EdgeSchema = z.object({
110
110
  })
111
111
  .nullable()
112
112
  .optional(),
113
+ /**
114
+ * Optional provenance for P4 usage edges. It lets the project resolver replace
115
+ * only import-derived usages on a later refresh without deleting lexical calls
116
+ * already proven inside the source file.
117
+ */
118
+ origin: z.enum(['usage_local', 'usage_import', 'usage_textual']).optional(),
119
+ });
120
+ /** P4's three deliberately non-interchangeable usage classifications. */
121
+ export const UsageKindSchema = z.enum(['calls', 'references', 'possible_textual_match']);
122
+ /**
123
+ * An import-binding usage that is lexical in one file but needs the project-wide
124
+ * import pass before its target symbol is known. This is persisted on the shard,
125
+ * never surfaced as a `calls`/`references` edge until that target is unique.
126
+ */
127
+ export const ReferenceCandidateSchema = z.object({
128
+ from: z.string(),
129
+ kind: z.enum(['calls', 'references']),
130
+ module: z.string(),
131
+ imported_name: z.string(),
132
+ confidence: z.number().default(1.0),
133
+ source: z.object({
134
+ path: z.string(),
135
+ line: z.number().int().nullable().optional(),
136
+ }),
113
137
  });
114
138
  // --- Per-file shard (spec §5.3) ---
115
139
  export const ShardFreshnessSchema = z.object({
@@ -136,6 +160,8 @@ export const FileShardSchema = z.object({
136
160
  freshness: ShardFreshnessSchema,
137
161
  nodes: z.array(NodeSchema).default([]),
138
162
  edges: z.array(EdgeSchema).default([]),
163
+ /** Deferred P4 imported-binding usages; resolved by the whole-project pass. */
164
+ reference_candidates: z.array(ReferenceCandidateSchema).optional(),
139
165
  diagnostics: z.array(z.unknown()).default([]),
140
166
  });
141
167
  // --- manifest.json (spec §5.1) ---
@@ -243,6 +269,19 @@ export const ImportsIndexSchema = z.object({
243
269
  entries: z.record(z.string(), z.array(ImportIndexEntrySchema)).default({}),
244
270
  });
245
271
  // --- resolution index (P1d) — reverse dependency maps over the P1c graph ---
272
+ /**
273
+ * One concrete resolved edge which made an importer depend on a target. Kept
274
+ * alongside the compact aggregate fields on {@link DependencyIndexEntrySchema}
275
+ * so impact analysis can explain every relation without re-reading all shards.
276
+ */
277
+ export const DependencyReasonSchema = z.object({
278
+ kind: z.enum(['resolves_to', 'imports_symbol']),
279
+ module: z.string().optional(),
280
+ imported: z.array(z.string()).default([]),
281
+ confidence: z.number().optional(),
282
+ /** Source line of the import edge when the extractor supplied one. */
283
+ source_line: z.number().int().nullable().optional(),
284
+ });
246
285
  /**
247
286
  * One DEPENDENT of a target (file or symbol): the importing file + enough metadata
248
287
  * to lazy-validate it (file_id) and explain WHY it appears (module specifier the
@@ -259,6 +298,8 @@ export const DependencyIndexEntrySchema = z.object({
259
298
  imported: z.array(z.string()).default([]),
260
299
  /** Resolution edge confidence (inherited from the A file resolution). */
261
300
  confidence: z.number().optional(),
301
+ /** Every resolved edge merged into this importer/target row, source ordered. */
302
+ reasons: z.array(DependencyReasonSchema).default([]),
262
303
  });
263
304
  /**
264
305
  * Reverse dependency index (P1d): "who imports this target". Built at refresh from
@@ -267,6 +308,19 @@ export const DependencyIndexEntrySchema = z.object({
267
308
  * read-path scan of every shard. Forward deps are read straight from a target's own
268
309
  * shard, so they need no index.
269
310
  */
311
+ /** A persisted, high-confidence P4 usage cause targeting one symbol. */
312
+ export const UsageReasonSchema = z.object({
313
+ kind: z.enum(['calls', 'references']),
314
+ caller_node_id: z.string(),
315
+ confidence: z.number(),
316
+ source_line: z.number().int().nullable().optional(),
317
+ });
318
+ /** One importing file's lexical uses of a target symbol. */
319
+ export const UsageIndexEntrySchema = z.object({
320
+ path: z.string(),
321
+ file_id: Sha256Hash,
322
+ reasons: z.array(UsageReasonSchema).default([]),
323
+ });
270
324
  export const ResolutionIndexSchema = z.object({
271
325
  schema_version: z.number().int().default(CODE_MAP_SCHEMA_VERSION),
272
326
  project_id: z.string(),
@@ -275,6 +329,8 @@ export const ResolutionIndexSchema = z.object({
275
329
  dependents_by_file: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
276
330
  /** Keys are TARGET symbol node ids (reverse `imports_symbol`). */
277
331
  dependents_by_symbol: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
332
+ /** Keys are TARGET symbol ids; only proven P4 call/reference usages are indexed. */
333
+ usages_by_symbol: z.record(z.string(), z.array(UsageIndexEntrySchema)).default({}),
278
334
  });
279
335
  // --- .lock (spec §5.8) ---
280
336
  export const CodeLockSchema = z.object({
@@ -292,20 +348,25 @@ export const CodeLockSchema = z.object({
292
348
  stale_after_ms: z.number().int(),
293
349
  });
294
350
  /**
295
- * spec §9 (pln#601) the COARSE, surface-uniform rollup of the detailed 7-value
296
- * {@link FreshnessStatus}. find/brief/status/work each expose their own precise
297
- * status, but an agent wants one consistent top-line signal to decide "trust this
298
- * or refresh first" without memorizing which `stale_*` variant applies. `coarse`
299
- * collapses the detail: every `stale_*` → `stale`, `missing_index` → `missing`,
300
- * `partial`/`fresh` unchanged. Derived (never independently authored) via
301
- * `coarseFreshness()` so it can never contradict `status`.
351
+ * The one agent-facing freshness signal. Detailed index causes and per-call
352
+ * spot-check observations intentionally live under `FreshnessBadge.details`.
302
353
  */
303
354
  export const CoarseFreshnessSchema = z.enum(['fresh', 'stale', 'partial', 'missing']);
304
355
  /** Freshness badge attached to every agent-facing read response (spec §9). */
305
356
  export const FreshnessBadgeSchema = z.object({
357
+ /**
358
+ * Stable top-line signal shared by work/status/find/brief. It MUST NOT be
359
+ * changed by a query's bounded spot-check: that observation belongs in
360
+ * `details.spot_check`, so an agent never sees incompatible badges for the
361
+ * same index state.
362
+ */
363
+ freshness: CoarseFreshnessSchema,
364
+ /**
365
+ * Detailed index classification retained for API compatibility. It describes
366
+ * the index only (never a query spot-check); new consumers branch on
367
+ * `freshness` and inspect `details` for the reason.
368
+ */
306
369
  status: FreshnessStatusSchema,
307
- /** pln#601 — coarse rollup of `status`, uniform across all read surfaces. */
308
- coarse: CoarseFreshnessSchema.optional(),
309
370
  details: z.record(z.string(), z.unknown()).default({}),
310
371
  });
311
372
  //# sourceMappingURL=types.js.map
@@ -47,6 +47,12 @@ export const UniversalEdgeKinds = [
47
47
  'resolves_to',
48
48
  'imports_symbol',
49
49
  'tests_for',
50
+ // P4 usages: a direct lexical invocation, a non-call lexical binding use, or
51
+ // an explicitly low-confidence text/property hint. Only `calls` is a proven
52
+ // invocation; consumers must never promote `possible_textual_match`.
53
+ 'calls',
54
+ 'references',
55
+ 'possible_textual_match',
50
56
  'extends',
51
57
  'implements',
52
58
  'annotates',
@@ -18,7 +18,7 @@
18
18
  * bclaw_work beyond that bounded wait (rule §6 rule 8).
19
19
  */
20
20
  import { readManifest } from './store.js';
21
- import { withCoarse } from './freshness.js';
21
+ import { withFreshness } from './freshness.js';
22
22
  import { readCodeLock, isLockAbandoned } from './lock.js';
23
23
  import { codeMapDir, lockPath } from './paths.js';
24
24
  import { JsonlBackend } from './backend.js';
@@ -108,9 +108,9 @@ export async function codeMapWorkSection(cwd, opts = {}) {
108
108
  return {
109
109
  enabled: true,
110
110
  matches: out.matches,
111
- freshness_badge: withCoarse({
111
+ freshness_badge: withFreshness({
112
112
  status: 'partial',
113
- details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
113
+ details: { spot_check: { status: 'partial', partial_reason: 'code_map_lock_active' }, lock_wait_ms: lockWaitMs },
114
114
  }),
115
115
  lock_wait_ms: lockWaitMs,
116
116
  };
@@ -122,9 +122,9 @@ export async function codeMapWorkSection(cwd, opts = {}) {
122
122
  return {
123
123
  enabled: true,
124
124
  matches: [],
125
- freshness_badge: withCoarse({
125
+ freshness_badge: withFreshness({
126
126
  status: 'partial',
127
- details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
127
+ details: { spot_check: { status: 'partial', partial_reason: 'code_map_lock_active' }, lock_wait_ms: lockWaitMs },
128
128
  }),
129
129
  lock_wait_ms: lockWaitMs,
130
130
  };
@@ -136,7 +136,7 @@ export async function codeMapWorkSection(cwd, opts = {}) {
136
136
  enabled: true,
137
137
  missing_index: 'Code Map index is empty for this project. Run `brainclaw code-map refresh --all` (or bclaw_code_refresh) before relying on find/brief.',
138
138
  matches: [],
139
- freshness_badge: withCoarse({ status: 'missing_index', details: {} }),
139
+ freshness_badge: withFreshness({ status: 'missing_index', details: {} }),
140
140
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
141
141
  };
142
142
  }
@@ -144,16 +144,14 @@ export async function codeMapWorkSection(cwd, opts = {}) {
144
144
  // lazy read-path check (§6.1) returns the true freshness badge, so stale
145
145
  // results are surfaced WITH the stale badge rather than hidden.
146
146
  if (!query) {
147
+ // Use the same read-only index observation as bclaw_code_status. This keeps
148
+ // bclaw_work aligned with status/find/brief for git-HEAD drift too, while
149
+ // still avoiding any lazy refresh or source parsing.
150
+ const status = await backend.status({ cwd: ctx.cwd, preferredDirName: ctx.preferredDirName });
147
151
  return {
148
152
  enabled: true,
149
153
  matches: [],
150
- freshness_badge: withCoarse({
151
- status: manifest.freshness.status,
152
- details: {
153
- stale_file_count: manifest.freshness.stale_file_count,
154
- partial_reason: manifest.freshness.partial_reason,
155
- },
156
- }),
154
+ freshness_badge: status.freshness_badge,
157
155
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
158
156
  };
159
157
  }
@@ -193,7 +191,7 @@ export function codeMapRefreshNextActions(section) {
193
191
  },
194
192
  ];
195
193
  }
196
- if (section.freshness_badge?.status?.startsWith('stale_')) {
194
+ if (section.freshness_badge?.freshness === 'stale') {
197
195
  return [
198
196
  {
199
197
  tool: 'bclaw_code_refresh',
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { readAuditLog } from './audit.js';
3
3
  import { listCandidates } from './candidates.js';
4
- import { entityRecordPaths } from './io.js';
4
+ import { sessionSnapshotRecordPaths } from './io.js';
5
5
  import { loadVersionedJsonFile } from './migration.js';
6
6
  import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
7
7
  import { SessionSnapshotSchema } from './schema.js';
@@ -44,11 +44,25 @@ export function resolveContextDiffSince(options) {
44
44
  * Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
45
45
  */
46
46
  function loadSessionSnapshot(sessionId, cwd) {
47
- for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
47
+ // pln#670 snapshots carry a type-suffixed name (`<id>.snapshot.json`);
48
+ // the helper also probes the pre-split `<id>.json` layouts.
49
+ for (const snapshotPath of sessionSnapshotRecordPaths(sessionId, cwd ?? process.cwd())) {
48
50
  if (!fs.existsSync(snapshotPath))
49
51
  continue;
50
52
  try {
51
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
53
+ // Deliberately NOT type-strict (pln#649 + pln#670): this reader answers
54
+ // "when did session X start" to anchor the diff window, and a
55
+ // current_session record for the same id is an equally authoritative
56
+ // source of `started_at`. The suffixed probes come first, so a real
57
+ // snapshot (richer: context_target, git_sha) still wins when both exist.
58
+ // Contrast with session-start's loadSessionSnapshot, which must stay
59
+ // strict — its consumers treat the record as a genuine snapshot.
60
+ const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
61
+ // A suffix-bearing lookup can construct another snapshot's path (codex
62
+ // review). Its started_at is useful only when the stored identity matches.
63
+ if (snapshot.session_id !== sessionId)
64
+ continue;
65
+ return snapshot;
52
66
  }
53
67
  catch {
54
68
  // An unparseable record in one layout must not mask a good one in the other.
@@ -28,7 +28,7 @@ import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, tran
28
28
  import { listAgentRuns } from './agentruns.js';
29
29
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
30
30
  import { isObserverMode } from './observer-mode.js';
31
- import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
31
+ import { deleteRuntimeNote, listRuntimeNotes, parkRuntimeNoteBackup, saveRuntimeNote, } from './runtime.js';
32
32
  import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
33
33
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
34
34
  import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
@@ -853,10 +853,22 @@ export function removeEntity(name, id, cwd, purge = false) {
853
853
  const note = notes.find((n) => n.id === id);
854
854
  if (!note)
855
855
  throw new EntityNotFoundError(name, id);
856
+ // trp_dc9ca61e — the tool contract says "archives by default", but this
857
+ // path hard-deleted regardless of `purge` (runtime_note has no lifecycle,
858
+ // so there was no soft state to land in). Default remove now parks the
859
+ // raw record under gc-backups — the same net the retention sweeps use —
860
+ // and fails CLOSED when the park is impossible: silently downgrading an
861
+ // archive into a hard-delete is the defect being fixed.
862
+ if (!purge) {
863
+ const backupPath = parkRuntimeNoteBackup(note, cwd);
864
+ if (!backupPath) {
865
+ throw new Error(`runtime_note '${id}' could not be archived to gc-backups; pass purge:true to hard-delete`);
866
+ }
867
+ }
856
868
  const ok = deleteRuntimeNote(note, cwd);
857
869
  if (!ok)
858
870
  throw new EntityNotFoundError(name, id);
859
- return { entity: name, id, archived: false, purged: true };
871
+ return { entity: name, id, archived: !purge, purged: purge };
860
872
  }
861
873
  case 'candidate': {
862
874
  // Remove = archive to rejected. `purge` would delete the file; not exposed yet.
@@ -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
  /**
@@ -179,16 +179,22 @@ export async function pushPending(options = {}) {
179
179
  throw new Error('Adresse du cloud inconnue : passez --url. Elle n\'est pas conservée par l\'appairage ' +
180
180
  '(état de connexion sans champ d\'URL), et aucune adresse n\'est devinée.');
181
181
  }
182
+ const pending = list('pending', cwd);
183
+ // File vide = rien à signer : sortir AVANT de résoudre l'identité. Le signataire est
184
+ // déduit de la première entrée pending ; le chercher sur une file vide transformait
185
+ // « tout est déjà parti » en erreur d'identité — vécu le 2026-08-10, juste après un
186
+ // envoi complet dont il ne restait que des conflits.
187
+ if (pending.length === 0)
188
+ return result;
182
189
  // L'identité SIGNATAIRE du transport : celle de l'agent qui a produit les enveloppes.
183
190
  // Elle est portée par l'entrée d'outbox, donc le transport n'a pas à deviner qui signe.
184
- const agentId = options.agentId ?? list('pending', cwd)[0]?.origin_agent_id;
191
+ const agentId = options.agentId ?? pending[0]?.origin_agent_id;
185
192
  const identity = agentId ? loadAgentSigningKey(agentId) : undefined;
186
193
  if (!identity) {
187
194
  throw new Error('Identité de signature introuvable : le cloud vérifie une signature de TRANSPORT ' +
188
195
  'liant envelope_id, rev et base_rev. Sans elle, chaque envoi est refusé en 422.');
189
196
  }
190
197
  const identityPem = identity.privateKeyPem;
191
- const pending = list('pending', cwd);
192
198
  const batch = options.limit ? pending.slice(0, options.limit) : pending;
193
199
  result.attempted = batch.length;
194
200
  if (options.dryRun)
@@ -214,7 +220,14 @@ export async function pushPending(options = {}) {
214
220
  // réel en écrasement silencieux du travail d'un autre appareil.
215
221
  if (res.status === 409) {
216
222
  const detail = (await res.clone().json().catch(() => ({})));
217
- const expected = detail['expected_base_rev'] ?? detail['expected'];
223
+ // `current_head_rev` est le nom que le serveur DÉPLOYÉ répond (projection.ts,
224
+ // REV_CONFLICT). Les deux autres sont des noms historiques gardés en repli.
225
+ // Dérive constatée le 2026-08-10 : le client lisait `expected_base_rev` sur une
226
+ // réponse qui ne l'a jamais porté — le recalage ne se déclenchait donc JAMAIS et
227
+ // chaque mise à jour finissait en conflit. Le test unitaire rejoue depuis la
228
+ // forme de réponse RÉELLE du serveur ; leçon dec#160/162, un contrat inter-
229
+ // services ne se vérifie que contre le service.
230
+ const expected = detail['current_head_rev'] ?? detail['expected_base_rev'] ?? detail['expected'];
218
231
  if (typeof expected === 'string' || typeof expected === 'number') {
219
232
  // On RESIGNE avec la nouvelle base_rev : c'est précisément ce que la signature
220
233
  // au niveau du transport rend possible, et qu'une signature figée à l'émission
@@ -123,7 +123,10 @@ export function ageStaleWarnings(warnings, cwd, options = {}) {
123
123
  const overflow = ids.length - shown.length;
124
124
  return `${ids.length} ${entity}${ids.length === 1 ? '' : 's'}: ${shown.join(', ')}${overflow > 0 ? ` +${overflow} more` : ''}`;
125
125
  });
126
- aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review, or bclaw_transition to retire.`;
126
+ // trp_dc9ca61e do not recommend bclaw_transition for runtime_notes: they
127
+ // have no lifecycle and the call errors. bclaw_remove (archive by default)
128
+ // is their retirement path.
129
+ aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review; retire with bclaw_transition, or bclaw_remove for runtime_notes (no lifecycle).`;
127
130
  }
128
131
  return { warnings: detail, aggregate, served_ids, folded_ids };
129
132
  }