ruvnet-brain 4.6.4 → 4.7.1
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/README.md +1 -1
- package/bin/install.mjs +65 -54
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +1 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.grok-plugin/plugin.json +12 -0
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -28
- package/plugin/hooks/grok-hooks.json +95 -0
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/host-adapters/grok.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +35 -27
- package/plugin/scripts/automated-run.mjs +42 -0
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/checkpoint-keys.mjs +46 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +6 -0
- package/plugin/scripts/continuity-events.mjs +90 -2
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-record.mjs +11 -40
- package/plugin/scripts/grok-hook-adapter.mjs +155 -0
- package/plugin/scripts/hook-failures.mjs +16 -0
- package/plugin/scripts/hook-shim.mjs +1 -2
- package/plugin/scripts/learn-capture.mjs +60 -42
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -341
- package/plugin/scripts/session-start-core.mjs +2 -12
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +68 -74
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-native-qualification.mjs +17 -5
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/model-routing-execution-adapters.mjs +24 -2
- package/scripts/model-weekly-cycle.mjs +13 -12
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +21 -30
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/user-model-prompt-hook.mjs +11 -10
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -351
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -267
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -1,496 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export const PROJECT_PROGRESSION_SCHEMA = 'ruvnet-brain.project-progression';
|
|
4
|
-
export const PROJECT_PROGRESSION_VERSION = 1;
|
|
5
|
-
|
|
6
|
-
function canonicalize(value) {
|
|
7
|
-
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
8
|
-
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
9
|
-
if (Array.isArray(value)) return value.map(canonicalize);
|
|
10
|
-
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
11
|
-
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
12
|
-
}
|
|
13
|
-
throw new TypeError('progression values must be JSON-compatible');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function digestCanonical(value) {
|
|
17
|
-
return crypto.createHash('sha256').update(JSON.stringify(isCanonicalJson(value) ? value : canonicalize(value))).digest('hex');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Stored snapshots are already canonical. Verify that fact without constructing a second
|
|
21
|
-
// complete object graph; arbitrary callers still receive the original sorting/validation path.
|
|
22
|
-
function isCanonicalJson(value) {
|
|
23
|
-
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
24
|
-
if (typeof value === 'number') return Number.isFinite(value);
|
|
25
|
-
if (Array.isArray(value)) {
|
|
26
|
-
if (Object.getPrototypeOf(value) !== Array.prototype || ['toJSON', 'map', 'constructor'].some(key => Object.hasOwn(value, key))) return false;
|
|
27
|
-
for (let index = 0; index < value.length; index++) {
|
|
28
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
|
29
|
-
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
30
|
-
}
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
34
|
-
if (Object.hasOwn(value, 'toJSON')) return false;
|
|
35
|
-
let previous;
|
|
36
|
-
for (const key of Object.keys(value)) {
|
|
37
|
-
if (previous !== undefined && previous > key) return false;
|
|
38
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
39
|
-
if (!Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
40
|
-
previous = key;
|
|
41
|
-
}
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function requireRecord(value, label) {
|
|
46
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function requireString(value, label) {
|
|
50
|
-
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} must be a non-empty string`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function keyPart(value) {
|
|
54
|
-
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'unknown';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const SOURCE_FIELDS = Object.freeze([
|
|
58
|
-
'checkoutPath', 'worktreeId', 'branch', 'head', 'trackedDigest', 'untrackedDigest', 'dirtyTreeDigest',
|
|
59
|
-
]);
|
|
60
|
-
const STATE_ARRAY_FIELDS = Object.freeze([
|
|
61
|
-
'plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands',
|
|
62
|
-
'proofArtifacts', 'untested', 'resumeConflicts',
|
|
63
|
-
]);
|
|
64
|
-
const STATE_VALUE_FIELDS = Object.freeze([
|
|
65
|
-
'currentGoal', 'acceptanceContract', 'activeProcess', 'activeStep', 'nextAction',
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
// Canonical field ownership. Producers may fill a field only from the listed durable sources;
|
|
69
|
-
// readers/checkpoints preserve this provenance and surface conflicts instead of silently choosing.
|
|
70
|
-
export const PROGRESSION_FIELD_AUTHORITY = Object.freeze({
|
|
71
|
-
currentGoal: Object.freeze(['ledger', 'prior-head', 'owner-note', 'transcript-derived']),
|
|
72
|
-
nextAction: Object.freeze(['ledger', 'prior-head']),
|
|
73
|
-
acceptanceContract: Object.freeze(['prior-head', 'ledger']),
|
|
74
|
-
plan: Object.freeze(['ledger', 'prior-head']),
|
|
75
|
-
completed: Object.freeze(['ledger', 'prior-head']),
|
|
76
|
-
inProgress: Object.freeze(['ledger', 'prior-head']),
|
|
77
|
-
decisions: Object.freeze(['ledger', 'owner-note', 'prior-head']),
|
|
78
|
-
changedFiles: Object.freeze(['git']),
|
|
79
|
-
sourceIdentity: Object.freeze(['git']),
|
|
80
|
-
});
|
|
81
|
-
export function fieldAuthorityAllows(field, source) {
|
|
82
|
-
return PROGRESSION_FIELD_AUTHORITY[field]?.includes(source) === true;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function eventKeyFor(value) {
|
|
86
|
-
const identityDigest = digestCanonical({
|
|
87
|
-
projectId: value.projectIdentity.id,
|
|
88
|
-
host: value.hostIdentity.host,
|
|
89
|
-
session: value.sessionIdentity,
|
|
90
|
-
sequence: value.sequence,
|
|
91
|
-
dedupId: value.dedupId,
|
|
92
|
-
});
|
|
93
|
-
return [
|
|
94
|
-
'project-progress-v1',
|
|
95
|
-
keyPart(value.projectIdentity.id),
|
|
96
|
-
keyPart(value.hostIdentity.host),
|
|
97
|
-
keyPart(value.sessionIdentity),
|
|
98
|
-
String(value.sequence).padStart(12, '0'),
|
|
99
|
-
identityDigest,
|
|
100
|
-
].join('-');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function secretKind(key) {
|
|
104
|
-
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
105
|
-
if (normalized.includes('privatekey')) return 'private-key';
|
|
106
|
-
if (normalized.includes('apikey')) return 'api-key';
|
|
107
|
-
if (normalized.includes('authorization')) return 'authorization';
|
|
108
|
-
if (normalized.includes('password') || normalized.includes('passwd')) return 'password';
|
|
109
|
-
if (normalized.includes('token')) return 'token';
|
|
110
|
-
if (normalized.includes('secret')) return 'secret';
|
|
111
|
-
if (normalized.includes('credential')) return 'credential';
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const INLINE_SECRETS = Object.freeze([
|
|
116
|
-
{
|
|
117
|
-
kind: 'private-key',
|
|
118
|
-
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
119
|
-
replace: '[REDACTED:private-key]',
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
kind: 'bearer-token',
|
|
123
|
-
pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
124
|
-
replace: 'Bearer [REDACTED:bearer-token]',
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
kind: 'api-key',
|
|
128
|
-
pattern: /\b(?:sk|api-key|ghp|github_pat)[-_][A-Za-z0-9._-]{8,}/gi,
|
|
129
|
-
replace: '[REDACTED:api-key]',
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
kind: 'password',
|
|
133
|
-
pattern: /\b(password|passwd)\s*[:=]\s*[^\s;,]+/gi,
|
|
134
|
-
replace: '$1=[REDACTED:password]',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
kind: 'token',
|
|
138
|
-
pattern: /\b(token|secret|credential)\s*[:=]\s*[^\s;,]+/gi,
|
|
139
|
-
replace: '$1=[REDACTED:token]',
|
|
140
|
-
},
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
|
-
// Validation needs a verdict, not a rebuilt redacted payload. Keep exactly the writer's
|
|
144
|
-
// key and replacement semantics, including already-redacted markers and nested values.
|
|
145
|
-
function containsUnredactedSecret(value) {
|
|
146
|
-
const kinds = new Map();
|
|
147
|
-
const visit = (current) => {
|
|
148
|
-
if (typeof current === 'string') {
|
|
149
|
-
if (!/PRIVATE KEY|Bearer|(?:sk|api-key|ghp|github_pat)[-_]|password|passwd|token|secret|credential/i.test(current)) return false;
|
|
150
|
-
for (const rule of INLINE_SECRETS) {
|
|
151
|
-
rule.pattern.lastIndex = 0;
|
|
152
|
-
if (current.replace(rule.pattern, rule.replace) !== current) return true;
|
|
153
|
-
}
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
if (Array.isArray(current)) {
|
|
157
|
-
for (const item of current) if (visit(item)) return true;
|
|
158
|
-
} else if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
|
|
159
|
-
for (const key of Object.keys(current)) {
|
|
160
|
-
if (!kinds.has(key)) kinds.set(key, secretKind(key));
|
|
161
|
-
const kind = kinds.get(key);
|
|
162
|
-
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
163
|
-
if (current[key] !== `[REDACTED:${kind}]`) return true;
|
|
164
|
-
} else if (visit(current[key])) return true;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return false;
|
|
168
|
-
};
|
|
169
|
-
return visit(value);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function redactProgression(value) {
|
|
173
|
-
const redactions = [];
|
|
174
|
-
const mark = (path, kind) => {
|
|
175
|
-
if (!redactions.some((row) => row.path === path && row.kind === kind)) redactions.push({ path, kind });
|
|
176
|
-
};
|
|
177
|
-
const walk = (current, currentPath) => {
|
|
178
|
-
if (typeof current === 'string') {
|
|
179
|
-
let output = current;
|
|
180
|
-
for (const rule of INLINE_SECRETS) {
|
|
181
|
-
rule.pattern.lastIndex = 0;
|
|
182
|
-
const replaced = output.replace(rule.pattern, rule.replace);
|
|
183
|
-
if (replaced !== output) {
|
|
184
|
-
mark(currentPath, rule.kind);
|
|
185
|
-
output = replaced;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return output;
|
|
189
|
-
}
|
|
190
|
-
if (Array.isArray(current)) return current.map((item, index) => walk(item, `${currentPath}[${index}]`));
|
|
191
|
-
if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
|
|
192
|
-
return Object.fromEntries(Object.keys(current).sort().map((key) => {
|
|
193
|
-
const path = `${currentPath}.${key}`;
|
|
194
|
-
const kind = secretKind(key);
|
|
195
|
-
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
196
|
-
if (current[key] !== `[REDACTED:${kind}]`) mark(path, kind);
|
|
197
|
-
return [key, `[REDACTED:${kind}]`];
|
|
198
|
-
}
|
|
199
|
-
return [key, walk(current[key], path)];
|
|
200
|
-
}));
|
|
201
|
-
}
|
|
202
|
-
return canonicalize(current);
|
|
203
|
-
};
|
|
204
|
-
const redacted = walk(value, '$');
|
|
205
|
-
redactions.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
|
|
206
|
-
return { value: redacted, redactions };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function createProgressionSnapshot(input) {
|
|
210
|
-
requireRecord(input, 'snapshot input');
|
|
211
|
-
requireRecord(input.projectIdentity, 'projectIdentity');
|
|
212
|
-
requireString(input.projectIdentity.id, 'projectIdentity.id');
|
|
213
|
-
requireString(input.projectIdentity.canonicalAgentDbPath, 'projectIdentity.canonicalAgentDbPath');
|
|
214
|
-
requireRecord(input.sourceIdentity, 'sourceIdentity');
|
|
215
|
-
for (const field of SOURCE_FIELDS) requireString(input.sourceIdentity[field], `sourceIdentity.${field}`);
|
|
216
|
-
requireRecord(input.hostIdentity, 'hostIdentity');
|
|
217
|
-
requireString(input.hostIdentity.host, 'hostIdentity.host');
|
|
218
|
-
requireString(input.hostIdentity.adapterVersion, 'hostIdentity.adapterVersion');
|
|
219
|
-
requireString(input.sessionIdentity, 'sessionIdentity');
|
|
220
|
-
requireString(input.dedupId, 'dedupId');
|
|
221
|
-
requireString(input.trigger, 'trigger');
|
|
222
|
-
requireRecord(input.completeProjectState, 'completeProjectState');
|
|
223
|
-
for (const field of STATE_ARRAY_FIELDS) {
|
|
224
|
-
if (!Array.isArray(input.completeProjectState[field])) throw new TypeError(`completeProjectState.${field} must be an array`);
|
|
225
|
-
}
|
|
226
|
-
for (const field of STATE_VALUE_FIELDS) {
|
|
227
|
-
if (!(field in input.completeProjectState)) throw new TypeError(`completeProjectState.${field} is required`);
|
|
228
|
-
}
|
|
229
|
-
if (!Number.isSafeInteger(input.sequence) || input.sequence < 0) throw new TypeError('sequence must be a non-negative safe integer');
|
|
230
|
-
if (!Number.isFinite(Date.parse(input.occurredAt))) throw new TypeError('occurredAt must be an ISO-8601 timestamp');
|
|
231
|
-
if (!Array.isArray(input.parentEventKeys)) throw new TypeError('parentEventKeys must be an array');
|
|
232
|
-
|
|
233
|
-
const draft = canonicalize({
|
|
234
|
-
schema: PROJECT_PROGRESSION_SCHEMA,
|
|
235
|
-
schemaVersion: PROJECT_PROGRESSION_VERSION,
|
|
236
|
-
dedupId: input.dedupId,
|
|
237
|
-
projectIdentity: input.projectIdentity,
|
|
238
|
-
hostIdentity: input.hostIdentity,
|
|
239
|
-
sessionIdentity: input.sessionIdentity,
|
|
240
|
-
sequence: input.sequence,
|
|
241
|
-
occurredAt: input.occurredAt,
|
|
242
|
-
trigger: input.trigger,
|
|
243
|
-
parentEventKeys: [...input.parentEventKeys].sort(),
|
|
244
|
-
completeProjectState: input.completeProjectState,
|
|
245
|
-
sourceIdentity: input.sourceIdentity,
|
|
246
|
-
});
|
|
247
|
-
const { value: redacted, redactions } = redactProgression(draft);
|
|
248
|
-
const snapshot = canonicalize({ ...redacted, eventKey: eventKeyFor(redacted), redactions });
|
|
249
|
-
return Object.freeze({ ...snapshot, payloadDigest: digestCanonical(snapshot) });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function validateProgressionSnapshot(snapshot, { expectedProjectIdentity } = {}) {
|
|
253
|
-
const errors = [];
|
|
254
|
-
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
255
|
-
return { ok: false, errors: ['snapshot must be an object'] };
|
|
256
|
-
}
|
|
257
|
-
if (snapshot.schema !== PROJECT_PROGRESSION_SCHEMA) errors.push('unsupported snapshot schema');
|
|
258
|
-
if (snapshot.schemaVersion !== PROJECT_PROGRESSION_VERSION) errors.push('unsupported snapshot version');
|
|
259
|
-
if (!snapshot.projectIdentity || typeof snapshot.projectIdentity !== 'object') errors.push('missing project identity');
|
|
260
|
-
if (!snapshot.hostIdentity || typeof snapshot.hostIdentity !== 'object') errors.push('missing host identity');
|
|
261
|
-
if (!snapshot.sourceIdentity || typeof snapshot.sourceIdentity !== 'object') errors.push('missing source identity');
|
|
262
|
-
if (!snapshot.completeProjectState || typeof snapshot.completeProjectState !== 'object') errors.push('missing complete project state');
|
|
263
|
-
if (!Array.isArray(snapshot.parentEventKeys)) errors.push('parentEventKeys must be an array');
|
|
264
|
-
if (!Array.isArray(snapshot.redactions)) errors.push('redactions must be an array');
|
|
265
|
-
if (!Number.isSafeInteger(snapshot.sequence) || snapshot.sequence < 0) errors.push('invalid sequence');
|
|
266
|
-
if (!Number.isFinite(Date.parse(snapshot.occurredAt))) errors.push('invalid occurredAt');
|
|
267
|
-
|
|
268
|
-
if (snapshot.sourceIdentity) {
|
|
269
|
-
for (const field of SOURCE_FIELDS) {
|
|
270
|
-
if (typeof snapshot.sourceIdentity[field] !== 'string' || !snapshot.sourceIdentity[field]) {
|
|
271
|
-
errors.push(`missing sourceIdentity.${field}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if (snapshot.completeProjectState) {
|
|
276
|
-
for (const field of STATE_ARRAY_FIELDS) {
|
|
277
|
-
if (!Array.isArray(snapshot.completeProjectState[field])) errors.push(`invalid completeProjectState.${field}`);
|
|
278
|
-
}
|
|
279
|
-
for (const field of STATE_VALUE_FIELDS) {
|
|
280
|
-
if (!(field in snapshot.completeProjectState)) errors.push(`missing completeProjectState.${field}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
if (expectedProjectIdentity && snapshot.projectIdentity) {
|
|
284
|
-
if (snapshot.projectIdentity.id !== expectedProjectIdentity.id) errors.push('foreign project id');
|
|
285
|
-
if (snapshot.projectIdentity.canonicalAgentDbPath !== expectedProjectIdentity.canonicalAgentDbPath) {
|
|
286
|
-
errors.push('foreign canonical AgentDB path');
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (snapshot.projectIdentity && snapshot.hostIdentity && typeof snapshot.sessionIdentity === 'string'
|
|
290
|
-
&& Number.isSafeInteger(snapshot.sequence) && typeof snapshot.dedupId === 'string') {
|
|
291
|
-
try {
|
|
292
|
-
if (snapshot.eventKey !== eventKeyFor(snapshot)) errors.push('event key mismatch');
|
|
293
|
-
} catch { errors.push('event key is unverifiable'); }
|
|
294
|
-
}
|
|
295
|
-
try {
|
|
296
|
-
if (Object.getPrototypeOf(snapshot) !== Object.prototype) throw new TypeError('snapshot must be a plain JSON object');
|
|
297
|
-
const { payloadDigest: _digest, ...body } = snapshot;
|
|
298
|
-
if (snapshot.payloadDigest !== digestCanonical(body)) errors.push('payload digest mismatch');
|
|
299
|
-
} catch { errors.push('payload is not canonical JSON'); }
|
|
300
|
-
if (Array.isArray(snapshot.redactions)
|
|
301
|
-
&& snapshot.redactions.some((row) => !row || typeof row.path !== 'string' || typeof row.kind !== 'string'
|
|
302
|
-
|| Object.keys(row).sort().join(',') !== 'kind,path')) errors.push('invalid redaction marker');
|
|
303
|
-
try {
|
|
304
|
-
if (containsUnredactedSecret(snapshot)) errors.push('unredacted secret material');
|
|
305
|
-
} catch { /* canonical validation already reports the malformed payload */ }
|
|
306
|
-
|
|
307
|
-
return { ok: errors.length === 0, errors: [...new Set(errors)] };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function rejectedRow(snapshot, reasons) {
|
|
311
|
-
return { eventKey: typeof snapshot?.eventKey === 'string' ? snapshot.eventKey : null, reasons: [...new Set(reasons)].sort() };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function sortRejected(rows) {
|
|
315
|
-
return rows.sort((left, right) => String(left.eventKey).localeCompare(String(right.eventKey))
|
|
316
|
-
|| left.reasons.join('|').localeCompare(right.reasons.join('|')));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function cyclicKeys(entries) {
|
|
320
|
-
const state = new Map();
|
|
321
|
-
const stack = [];
|
|
322
|
-
const cyclic = new Set();
|
|
323
|
-
const visit = (key) => {
|
|
324
|
-
if (state.get(key) === 2) return;
|
|
325
|
-
if (state.get(key) === 1) {
|
|
326
|
-
const start = stack.indexOf(key);
|
|
327
|
-
for (const member of stack.slice(start)) cyclic.add(member);
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
state.set(key, 1);
|
|
331
|
-
stack.push(key);
|
|
332
|
-
for (const parent of entries.get(key)?.parentEventKeys ?? []) if (entries.has(parent)) visit(parent);
|
|
333
|
-
stack.pop();
|
|
334
|
-
state.set(key, 2);
|
|
335
|
-
};
|
|
336
|
-
for (const key of [...entries.keys()].sort()) visit(key);
|
|
337
|
-
return cyclic;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function uniqueSorted(values) {
|
|
341
|
-
const byDigest = new Map();
|
|
342
|
-
for (const value of values) byDigest.set(digestCanonical(value), canonicalize(value));
|
|
343
|
-
return [...byDigest.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, value]) => value);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function conflict(field, heads, valueFor) {
|
|
347
|
-
const values = heads.map((head) => ({ head: head.eventKey, value: canonicalize(valueFor(head)) }));
|
|
348
|
-
return { field, values };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function planItemId(item) {
|
|
352
|
-
return typeof item?.id === 'string' && item.id ? item.id : digestCanonical(item);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function mergeHeads(heads) {
|
|
356
|
-
if (heads.length === 1) {
|
|
357
|
-
return canonicalize({
|
|
358
|
-
...heads[0].completeProjectState,
|
|
359
|
-
sourceIdentity: heads[0].sourceIdentity,
|
|
360
|
-
journalHeads: [heads[0].eventKey],
|
|
361
|
-
resumeConflicts: heads[0].completeProjectState.resumeConflicts,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const conflicts = [];
|
|
366
|
-
const state = {};
|
|
367
|
-
for (const field of STATE_ARRAY_FIELDS.filter((field) => !['plan', 'resumeConflicts'].includes(field))) {
|
|
368
|
-
state[field] = uniqueSorted(heads.flatMap((head) => head.completeProjectState[field]));
|
|
369
|
-
}
|
|
370
|
-
for (const field of STATE_VALUE_FIELDS) {
|
|
371
|
-
const values = uniqueSorted(heads.map((head) => head.completeProjectState[field]));
|
|
372
|
-
if (values.length === 1) state[field] = values[0];
|
|
373
|
-
else {
|
|
374
|
-
state[field] = null;
|
|
375
|
-
conflicts.push(conflict(field, heads, (head) => head.completeProjectState[field]));
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const planById = new Map();
|
|
380
|
-
for (const head of heads) {
|
|
381
|
-
for (const item of head.completeProjectState.plan) {
|
|
382
|
-
planById.set(planItemId(item), true);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
state.plan = [];
|
|
386
|
-
for (const id of [...planById.keys()].sort()) {
|
|
387
|
-
const rows = heads.map((head) => ({
|
|
388
|
-
head: head.eventKey,
|
|
389
|
-
value: head.completeProjectState.plan.find((item) => planItemId(item) === id) ?? null,
|
|
390
|
-
}));
|
|
391
|
-
const variants = uniqueSorted(rows.map((row) => row.value));
|
|
392
|
-
state.plan.push(...variants.filter((value) => value !== null));
|
|
393
|
-
if (variants.length > 1) {
|
|
394
|
-
conflicts.push({
|
|
395
|
-
field: `plan.${id}`,
|
|
396
|
-
values: rows.map((row) => ({ head: row.head, value: canonicalize(row.value) }))
|
|
397
|
-
.sort((left, right) => left.head.localeCompare(right.head)),
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// CARRY THE FIELDS THIS MERGE DOES NOT KNOW ABOUT (provenance, evidence, anything a later producer
|
|
403
|
-
// adds). The merge used to build `state` from its two hard-coded field lists alone, so every extra
|
|
404
|
-
// key silently VANISHED the moment a project had two heads — and provenance is exactly the kind of
|
|
405
|
-
// field whose disappearance turns "we guessed this from a transcript" into an unmarked fact.
|
|
406
|
-
const knownFields = new Set([...STATE_ARRAY_FIELDS, ...STATE_VALUE_FIELDS, 'journalHeads', 'sourceIdentity']);
|
|
407
|
-
const extraFields = [...new Set(heads.flatMap((head) => Object.keys(head.completeProjectState)))]
|
|
408
|
-
.filter((field) => !knownFields.has(field)).sort();
|
|
409
|
-
for (const field of extraFields) {
|
|
410
|
-
const values = uniqueSorted(heads.map((head) => head.completeProjectState[field] ?? null));
|
|
411
|
-
if (values.length === 1) state[field] = values[0];
|
|
412
|
-
else {
|
|
413
|
-
state[field] = null;
|
|
414
|
-
conflicts.push(conflict(field, heads, (head) => head.completeProjectState[field] ?? null));
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const sources = uniqueSorted(heads.map((head) => head.sourceIdentity));
|
|
419
|
-
if (sources.length === 1) state.sourceIdentity = sources[0];
|
|
420
|
-
else {
|
|
421
|
-
state.sourceIdentity = null;
|
|
422
|
-
conflicts.push(conflict('sourceIdentity', heads, (head) => head.sourceIdentity));
|
|
423
|
-
}
|
|
424
|
-
const carried = heads.flatMap((head) => head.completeProjectState.resumeConflicts);
|
|
425
|
-
state.resumeConflicts = uniqueSorted([...carried, ...conflicts]);
|
|
426
|
-
state.journalHeads = heads.map((head) => head.eventKey);
|
|
427
|
-
return canonicalize(state);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export function restoreProjectProgression(snapshots, { expectedProjectIdentity } = {}) {
|
|
431
|
-
if (!Array.isArray(snapshots)) throw new TypeError('snapshots must be an array');
|
|
432
|
-
const rejected = [];
|
|
433
|
-
const candidates = new Map();
|
|
434
|
-
const candidateGroups = new Map();
|
|
435
|
-
const ordered = [...snapshots].sort((left, right) => String(left?.eventKey).localeCompare(String(right?.eventKey))
|
|
436
|
-
|| String(left?.payloadDigest).localeCompare(String(right?.payloadDigest)));
|
|
437
|
-
|
|
438
|
-
for (const snapshot of ordered) {
|
|
439
|
-
const verdict = validateProgressionSnapshot(snapshot, { expectedProjectIdentity });
|
|
440
|
-
if (!verdict.ok) {
|
|
441
|
-
rejected.push(rejectedRow(snapshot, verdict.errors));
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
if (!candidateGroups.has(snapshot.eventKey)) candidateGroups.set(snapshot.eventKey, []);
|
|
445
|
-
candidateGroups.get(snapshot.eventKey).push(snapshot);
|
|
446
|
-
}
|
|
447
|
-
for (const [eventKey, group] of candidateGroups) {
|
|
448
|
-
if (new Set(group.map((snapshot) => snapshot.payloadDigest)).size > 1) {
|
|
449
|
-
rejected.push(...group.map((snapshot) => rejectedRow(snapshot, ['event key collision'])));
|
|
450
|
-
} else {
|
|
451
|
-
candidates.set(eventKey, group[0]);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const rejectBrokenEdges = () => {
|
|
456
|
-
const removals = [];
|
|
457
|
-
for (const snapshot of candidates.values()) {
|
|
458
|
-
if (snapshot.parentEventKeys.some((parent) => !candidates.has(parent))) {
|
|
459
|
-
removals.push([snapshot, 'missing parent']);
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
if (snapshot.parentEventKeys.some((parent) => {
|
|
463
|
-
const ancestor = candidates.get(parent);
|
|
464
|
-
return ancestor.sessionIdentity === snapshot.sessionIdentity && ancestor.sequence >= snapshot.sequence;
|
|
465
|
-
})) removals.push([snapshot, 'non-monotonic session sequence']);
|
|
466
|
-
}
|
|
467
|
-
for (const [snapshot, reason] of removals) {
|
|
468
|
-
candidates.delete(snapshot.eventKey);
|
|
469
|
-
rejected.push(rejectedRow(snapshot, [reason]));
|
|
470
|
-
}
|
|
471
|
-
return removals.length > 0;
|
|
472
|
-
};
|
|
473
|
-
while (rejectBrokenEdges()) { /* reject descendants of rejected evidence */ }
|
|
474
|
-
|
|
475
|
-
const cycles = cyclicKeys(candidates);
|
|
476
|
-
for (const key of [...cycles].sort()) {
|
|
477
|
-
const snapshot = candidates.get(key);
|
|
478
|
-
candidates.delete(key);
|
|
479
|
-
rejected.push(rejectedRow(snapshot, ['causal cycle']));
|
|
480
|
-
}
|
|
481
|
-
while (rejectBrokenEdges()) { /* reject descendants of cycles */ }
|
|
482
|
-
|
|
483
|
-
const consumed = new Set([...candidates.values()].flatMap((snapshot) => snapshot.parentEventKeys));
|
|
484
|
-
const heads = [...candidates.values()].filter((snapshot) => !consumed.has(snapshot.eventKey))
|
|
485
|
-
.sort((left, right) => left.eventKey.localeCompare(right.eventKey));
|
|
486
|
-
const headKeys = heads.map((snapshot) => snapshot.eventKey);
|
|
487
|
-
const causallyStale = [...candidates.keys()].filter((key) => !headKeys.includes(key)).sort();
|
|
488
|
-
rejected.push(...causallyStale.map((key) => rejectedRow(candidates.get(key), ['causally stale'])));
|
|
489
|
-
return {
|
|
490
|
-
ok: heads.length > 0,
|
|
491
|
-
heads: headKeys,
|
|
492
|
-
causallyStale,
|
|
493
|
-
rejected: sortRejected(rejected),
|
|
494
|
-
state: heads.length ? mergeHeads(heads) : null,
|
|
495
|
-
};
|
|
496
|
-
}
|