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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/code-map.js +120 -6
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +58 -6
- package/dist/commands/session-start.js +84 -13
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +162 -5
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +81 -15
- package/dist/core/code-map/impact.js +409 -0
- package/dist/core/code-map/indexes.js +64 -3
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +24 -6
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +285 -71
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +28 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +70 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/federation-pull.js +151 -3
- package/dist/core/federation-push.js +16 -3
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +69 -17
- package/dist/core/io.js +27 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/runtime.js +23 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +15 -12
- package/dist/facts.json +14 -11
- package/docs/cli.md +8 -0
- package/docs/code-map.md +60 -28
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- 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
|
-
*
|
|
296
|
-
*
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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?.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
217
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
package/dist/core/hint-aging.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|