wendkeep 0.66.4 → 0.67.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/CHANGELOG.md +50 -0
- package/README.en.md +78 -5
- package/README.md +78 -5
- package/docs/en/commands/costs-and-observability.md +21 -7
- package/docs/en/commands/maintenance-and-diagnostics.md +13 -1
- package/docs/en/commands/operating-profiles.md +65 -10
- package/docs/en/commands/sessions-and-import.md +22 -1
- package/docs/en/commands/verify.md +5 -3
- package/docs/pt-BR/commands/costs-and-observability.md +21 -7
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -1
- package/docs/pt-BR/commands/operating-profiles.md +66 -11
- package/docs/pt-BR/commands/sessions-and-import.md +20 -0
- package/docs/pt-BR/commands/verify.md +6 -3
- package/hooks/change-nag.mjs +8 -0
- package/hooks/codex-rollout-meta.mjs +112 -0
- package/hooks/codex-subagent-graph.mjs +903 -0
- package/hooks/harness-doctor.mjs +82 -1
- package/hooks/import-sessions.mjs +185 -50
- package/hooks/operating-profile-runtime.mjs +36 -2
- package/hooks/operating-profile-task-store.mjs +77 -0
- package/hooks/session-identity.mjs +40 -5
- package/hooks/session-observability-lifecycle.mjs +129 -0
- package/hooks/session-observability-state.mjs +241 -0
- package/hooks/session-observability-store.mjs +436 -0
- package/hooks/session-observability.mjs +647 -21
- package/hooks/session-stop.mjs +339 -11
- package/hooks/subagent-stop.mjs +266 -12
- package/hooks/subagent-usage.mjs +65 -0
- package/hooks/token-usage.mjs +81 -4
- package/package.json +3 -3
- package/packages/harness/src/operating-profile.mjs +127 -0
- package/packages/harness/src/sensors-core.mjs +41 -1
- package/packages/integrations/src/prompt-content.mjs +123 -0
- package/packages/integrations/src/transcripts.mjs +16 -10
- package/src/cost.mjs +40 -6
- package/src/doctor.mjs +4 -1
- package/src/profile.mjs +95 -17
- package/src/rebuild-costs.mjs +220 -34
- package/src/skills-seed.mjs +38 -2
- package/src/sync-defs.mjs +6 -1
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -8,7 +8,88 @@ import { buildEffectiveRequirementPackage, checkSpecsState, evaluateVerdict, tas
|
|
|
8
8
|
import { getLocale } from './locale.mjs';
|
|
9
9
|
import { priceForModel } from './token-usage.mjs';
|
|
10
10
|
import { countMissing, indexDerivedBySession, listSessionNotes, missingDerivedLinks } from './derived-sections.mjs';
|
|
11
|
-
import { readControl } from './obsidian-common.mjs';
|
|
11
|
+
import { readControl, readSessionRegistry } from './obsidian-common.mjs';
|
|
12
|
+
import { parseObservabilityCheckpoint } from './session-observability-state.mjs';
|
|
13
|
+
import { readObservabilityStore } from './session-observability-store.mjs';
|
|
14
|
+
import { assessObservabilityFreshness } from './session-observability-lifecycle.mjs';
|
|
15
|
+
|
|
16
|
+
export function checkSessionObservability(vaultBase, deps = {}) {
|
|
17
|
+
const readRegistry = deps.readRegistry || readSessionRegistry;
|
|
18
|
+
const readStore = deps.readStore || readObservabilityStore;
|
|
19
|
+
const readNote = deps.readNote || readFileSync;
|
|
20
|
+
const statSource = deps.statSource || statSync;
|
|
21
|
+
const registry = readRegistry(vaultBase);
|
|
22
|
+
const result = { ok: true, scanned: 0, healthy: 0, issues: [] };
|
|
23
|
+
const entries = Object.entries(registry?.sessions || {})
|
|
24
|
+
.map(([sessionId, entry]) => ({ sessionId, ...entry }))
|
|
25
|
+
.filter((entry) => entry.session_file)
|
|
26
|
+
.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.provider && entry.provider !== 'codex') continue;
|
|
30
|
+
const notePath = join(vaultBase, entry.session_file);
|
|
31
|
+
let content;
|
|
32
|
+
try { content = readNote(notePath, 'utf8'); } catch { continue; }
|
|
33
|
+
if (!entry.provider && /^provider:\s*["']?claude/m.test(content)) continue;
|
|
34
|
+
result.scanned += 1;
|
|
35
|
+
const command = `wendkeep cost rebuild --session ${entry.sessionId} --json`;
|
|
36
|
+
const checkpoint = parseObservabilityCheckpoint(content);
|
|
37
|
+
if (!checkpoint) {
|
|
38
|
+
result.issues.push({
|
|
39
|
+
sessionId: entry.sessionId, status: 'legacy', diagnostics: [], command,
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (checkpoint.state === 'degraded') {
|
|
44
|
+
result.issues.push({
|
|
45
|
+
sessionId: entry.sessionId,
|
|
46
|
+
status: 'degraded',
|
|
47
|
+
diagnostics: checkpoint.diagnostics,
|
|
48
|
+
command,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let runtime;
|
|
54
|
+
try { runtime = readStore(vaultBase, entry.sessionId); } catch {
|
|
55
|
+
runtime = null;
|
|
56
|
+
}
|
|
57
|
+
const assessment = assessObservabilityFreshness({
|
|
58
|
+
checkpoint,
|
|
59
|
+
runtimeState: runtime,
|
|
60
|
+
statSource,
|
|
61
|
+
});
|
|
62
|
+
if (!assessment.fresh) {
|
|
63
|
+
result.issues.push({
|
|
64
|
+
sessionId: entry.sessionId,
|
|
65
|
+
status: assessment.status,
|
|
66
|
+
diagnostics: assessment.diagnostics,
|
|
67
|
+
command,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
result.healthy += 1;
|
|
72
|
+
}
|
|
73
|
+
result.ok = result.issues.length === 0;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderSessionObservabilityLines(result) {
|
|
78
|
+
const issues = result?.issues || [];
|
|
79
|
+
const lines = [
|
|
80
|
+
`[observabilidade] ${result?.healthy || 0} saudável(is) · ${issues.length} reparável(is)`,
|
|
81
|
+
];
|
|
82
|
+
for (const issue of issues) {
|
|
83
|
+
const diagnostics = issue.diagnostics?.length
|
|
84
|
+
? ` (${issue.diagnostics.map(({ code, count }) => `${code}:${count}`).join(', ')})`
|
|
85
|
+
: '';
|
|
86
|
+
lines.push(` ✗ ${issue.sessionId}: ${issue.status}${diagnostics}`);
|
|
87
|
+
lines.push(` → ${issue.command}`);
|
|
88
|
+
lines.push(` → ${issue.command} --apply`);
|
|
89
|
+
}
|
|
90
|
+
if (!issues.length) lines.push(' ✓ frontiers, checkpoints e manifests consistentes');
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
12
93
|
|
|
13
94
|
export function checkHarness(vaultBase, projectRoot) {
|
|
14
95
|
const loc = getLocale(vaultBase);
|
|
@@ -16,10 +16,20 @@ import {
|
|
|
16
16
|
} from './session-stop.mjs';
|
|
17
17
|
import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
|
|
18
18
|
import { createLinkedNotes } from './linked-notes.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
composeSessionObservability,
|
|
21
|
+
publishSessionObservability,
|
|
22
|
+
} from './session-observability.mjs';
|
|
20
23
|
import { readSessionRegistry, upsertSessionRegistry, removeSessionRegistryEntry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
|
|
21
24
|
import { getLocale } from './locale.mjs';
|
|
22
25
|
import { captureProseDecisions } from './decision-capture.mjs';
|
|
26
|
+
import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
|
|
27
|
+
import {
|
|
28
|
+
parseObservabilityCheckpoint,
|
|
29
|
+
sanitizeObservabilityDiagnostics,
|
|
30
|
+
} from './session-observability-state.mjs';
|
|
31
|
+
import { readObservabilityStore } from './session-observability-store.mjs';
|
|
32
|
+
import { assessObservabilityFreshness } from './session-observability-lifecycle.mjs';
|
|
23
33
|
|
|
24
34
|
// Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
|
|
25
35
|
// path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
|
|
@@ -97,41 +107,6 @@ function readPrefix(path, bytes = 4096) {
|
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
// Read the first physical line in full, growing the buffer until a newline is found (capped).
|
|
101
|
-
// A fixed prefix truncated any rollout whose session_meta line exceeded the window, silently
|
|
102
|
-
// dropping that session from discovery — Codex meta lines can be large (env, git, instructions).
|
|
103
|
-
function readFirstLine(path, maxBytes = 4 * 1024 * 1024) {
|
|
104
|
-
let fd;
|
|
105
|
-
try {
|
|
106
|
-
fd = openSync(path, 'r');
|
|
107
|
-
const chunk = Buffer.alloc(65536);
|
|
108
|
-
let acc = '';
|
|
109
|
-
let pos = 0;
|
|
110
|
-
while (pos < maxBytes) {
|
|
111
|
-
const n = readSync(fd, chunk, 0, chunk.length, pos);
|
|
112
|
-
if (n <= 0) break;
|
|
113
|
-
acc += chunk.slice(0, n).toString('utf-8');
|
|
114
|
-
const nl = acc.indexOf('\n');
|
|
115
|
-
if (nl >= 0) return acc.slice(0, nl);
|
|
116
|
-
pos += n;
|
|
117
|
-
}
|
|
118
|
-
return acc; // single-line file, or gave up at the cap
|
|
119
|
-
} catch {
|
|
120
|
-
return '';
|
|
121
|
-
} finally {
|
|
122
|
-
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Pull the session_meta payload (id + cwd). session_meta is line 1 of a rollout.
|
|
127
|
-
function readSessionMeta(path) {
|
|
128
|
-
const line = readFirstLine(path);
|
|
129
|
-
if (!line.trim()) return null;
|
|
130
|
-
let e;
|
|
131
|
-
try { e = JSON.parse(line); } catch { return null; }
|
|
132
|
-
return e.type === 'session_meta' ? (e.payload || {}) : null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
110
|
// The `session_id` recorded in a note's frontmatter (empty when absent).
|
|
136
111
|
function noteSessionId(path) {
|
|
137
112
|
const m = readPrefix(path, 2048).match(/^session_id:\s*["']?([^"'\r\n]+)["']?\s*$/m);
|
|
@@ -189,8 +164,9 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
|
|
|
189
164
|
if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
|
|
190
165
|
const transcripts = [];
|
|
191
166
|
for (const path of walkFiles(dir, /\.jsonl$/i)) {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
167
|
+
const metaResult = readCodexRolloutMeta(path);
|
|
168
|
+
if (!metaResult.ok || !metaResult.meta.id) continue;
|
|
169
|
+
const meta = metaResult.meta;
|
|
194
170
|
if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
|
|
195
171
|
// A subagent thread's rollout is a SIBLING file of its parent's — same dir, own id. The
|
|
196
172
|
// meta says what the file IS; ignoring it turned hierarchy into a duplicate session note.
|
|
@@ -248,11 +224,6 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
248
224
|
insertIteration(absPath, block, turn.turnId, tx, vaultBase);
|
|
249
225
|
}
|
|
250
226
|
|
|
251
|
-
// Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
|
|
252
|
-
try {
|
|
253
|
-
updateSessionObservability({ vaultBase, sessionPath: absPath, transcriptPath: txPath });
|
|
254
|
-
} catch { /* observability is best-effort */ }
|
|
255
|
-
|
|
256
227
|
// Finalize: derived notes + closing section + ended_at from the last turn.
|
|
257
228
|
const endedAt = formatLocalIso(endDate);
|
|
258
229
|
const created = mergeCreatedNotes(
|
|
@@ -271,6 +242,17 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
271
242
|
imported: true,
|
|
272
243
|
});
|
|
273
244
|
|
|
245
|
+
// Materialize only after the authoritative registry entry exists. This gives import the
|
|
246
|
+
// same causal CAS + runtime manifest as live hooks instead of a note-only legacy snapshot.
|
|
247
|
+
try {
|
|
248
|
+
reconcileImportedObservability({
|
|
249
|
+
vaultBase,
|
|
250
|
+
sessionId,
|
|
251
|
+
sessionPath: absPath,
|
|
252
|
+
transcriptPath: txPath,
|
|
253
|
+
});
|
|
254
|
+
} catch { /* observability is best-effort and reported on a later reconciliation */ }
|
|
255
|
+
|
|
274
256
|
return { sessionId, relPath, turns: turns.length };
|
|
275
257
|
}
|
|
276
258
|
|
|
@@ -333,12 +315,105 @@ export function stampSessionIds(vaultBase) {
|
|
|
333
315
|
return report;
|
|
334
316
|
}
|
|
335
317
|
|
|
318
|
+
export function reconcileImportedObservability({
|
|
319
|
+
vaultBase,
|
|
320
|
+
sessionId,
|
|
321
|
+
sessionPath,
|
|
322
|
+
transcriptPath,
|
|
323
|
+
dryRun = false,
|
|
324
|
+
} = {}, dependencies = {}) {
|
|
325
|
+
const readRegistry = dependencies.readRegistry || readSessionRegistry;
|
|
326
|
+
const readStore = dependencies.readStore || readObservabilityStore;
|
|
327
|
+
const compose = dependencies.compose || composeSessionObservability;
|
|
328
|
+
const publish = dependencies.publish || publishSessionObservability;
|
|
329
|
+
if (!vaultBase || !sessionId || !sessionPath || !existsSync(sessionPath)) {
|
|
330
|
+
return {
|
|
331
|
+
status: 'degraded',
|
|
332
|
+
diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let entry;
|
|
337
|
+
let content;
|
|
338
|
+
let runtimeState;
|
|
339
|
+
try {
|
|
340
|
+
entry = readRegistry(vaultBase)?.sessions?.[sessionId];
|
|
341
|
+
content = readFileSync(sessionPath, 'utf8');
|
|
342
|
+
runtimeState = readStore(vaultBase, sessionId);
|
|
343
|
+
} catch {
|
|
344
|
+
return { status: 'degraded', diagnostics: [{ code: 'CACHE_INVALID', count: 1 }] };
|
|
345
|
+
}
|
|
346
|
+
if (!entry) {
|
|
347
|
+
return {
|
|
348
|
+
status: 'degraded',
|
|
349
|
+
diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const checkpoint = parseObservabilityCheckpoint(content);
|
|
353
|
+
const assessment = assessObservabilityFreshness({ checkpoint, runtimeState });
|
|
354
|
+
if (assessment.fresh) return { status: 'fresh', diagnostics: [] };
|
|
355
|
+
|
|
356
|
+
let candidate;
|
|
357
|
+
try {
|
|
358
|
+
candidate = compose({
|
|
359
|
+
sessionContent: content,
|
|
360
|
+
sessionEntry: { ...entry, transcript_path: entry.transcript_path || transcriptPath },
|
|
361
|
+
canonicalConversationId: sessionId,
|
|
362
|
+
runtimeState,
|
|
363
|
+
allowNone: true,
|
|
364
|
+
mode: 'offline',
|
|
365
|
+
});
|
|
366
|
+
} catch {
|
|
367
|
+
return {
|
|
368
|
+
status: 'degraded',
|
|
369
|
+
diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const diagnostics = sanitizeObservabilityDiagnostics(candidate?.diagnostics || []);
|
|
373
|
+
if (candidate?.state === 'degraded') return { status: 'degraded', diagnostics };
|
|
374
|
+
if (candidate?.state !== 'complete' && candidate?.state !== 'none') {
|
|
375
|
+
return {
|
|
376
|
+
status: 'degraded',
|
|
377
|
+
diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (dryRun) return { status: 'would-reconcile', diagnostics };
|
|
381
|
+
|
|
382
|
+
let outcome;
|
|
383
|
+
try {
|
|
384
|
+
outcome = publish({
|
|
385
|
+
vaultBase,
|
|
386
|
+
sessionPath,
|
|
387
|
+
canonicalConversationId: sessionId,
|
|
388
|
+
candidate,
|
|
389
|
+
caller: 'import-reconcile',
|
|
390
|
+
allowSourceRefresh: true,
|
|
391
|
+
allowDegradedRecovery: true,
|
|
392
|
+
});
|
|
393
|
+
} catch {
|
|
394
|
+
return { status: 'degraded', diagnostics: [{ code: 'CACHE_INVALID', count: 1 }] };
|
|
395
|
+
}
|
|
396
|
+
if (outcome?.status === 'published' || outcome?.status === 'unchanged') {
|
|
397
|
+
return { status: 'published', diagnostics };
|
|
398
|
+
}
|
|
399
|
+
if (outcome?.status === 'stale' || outcome?.status === 'conflict') {
|
|
400
|
+
return {
|
|
401
|
+
status: 'stale',
|
|
402
|
+
diagnostics: [{ code: 'STALE_FRONTIER', count: 1 }],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return { status: 'degraded', diagnostics };
|
|
406
|
+
}
|
|
407
|
+
|
|
336
408
|
// Import every not-yet-captured transcript from the requested source(s). Deduped by session_id
|
|
337
409
|
// against the registry. Options: { projectPath, source ('all'|'claude'|'codex'), from, codexFrom,
|
|
338
410
|
// since (ISO/date), limit, dryRun }. importSession is provider-agnostic, so both sources share
|
|
339
411
|
// the same dedup + import loop.
|
|
340
412
|
export function runImport(vaultBase, opts = {}) {
|
|
341
|
-
const {
|
|
413
|
+
const {
|
|
414
|
+
projectPath = process.cwd(), source = 'all', from = '', codexFrom = '', since = '',
|
|
415
|
+
limit = 0, dryRun = false, reconcileObservability = reconcileImportedObservability,
|
|
416
|
+
} = opts;
|
|
342
417
|
const src = String(source).toLowerCase();
|
|
343
418
|
const transcripts = [];
|
|
344
419
|
let claudeDir = '';
|
|
@@ -355,7 +430,57 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
355
430
|
}
|
|
356
431
|
const notes = capturedSessionNotes(vaultBase);
|
|
357
432
|
const sinceMs = since ? Date.parse(since) : 0;
|
|
358
|
-
const report = {
|
|
433
|
+
const report = {
|
|
434
|
+
source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0,
|
|
435
|
+
repaired: 0, skipped: 0, subagents: 0, observabilityReconciled: 0,
|
|
436
|
+
observabilityFresh: 0, observabilityDegraded: 0, errors: [], sessions: [],
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const degradedObservability = () => ({
|
|
440
|
+
status: 'degraded',
|
|
441
|
+
diagnostics: [{ code: 'CACHE_INVALID', count: 1 }],
|
|
442
|
+
});
|
|
443
|
+
const reportableObservabilityStatuses = new Set([
|
|
444
|
+
'degraded', 'fresh', 'published', 'stale', 'unchanged', 'would-reconcile',
|
|
445
|
+
]);
|
|
446
|
+
const sanitizeReportedObservability = (result) => {
|
|
447
|
+
try {
|
|
448
|
+
const status = String(result?.status || 'degraded');
|
|
449
|
+
if (!reportableObservabilityStatuses.has(status)) return degradedObservability();
|
|
450
|
+
const projected = (result?.diagnostics || []).map((diagnostic) => ({
|
|
451
|
+
code: diagnostic?.code,
|
|
452
|
+
count: diagnostic?.count,
|
|
453
|
+
}));
|
|
454
|
+
return { status, diagnostics: sanitizeObservabilityDiagnostics(projected) };
|
|
455
|
+
} catch {
|
|
456
|
+
return degradedObservability();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const reconcileExistingObservability = (transcript, sessionPath, turns) => {
|
|
461
|
+
if (typeof reconcileObservability !== 'function') return null;
|
|
462
|
+
let result;
|
|
463
|
+
try {
|
|
464
|
+
result = reconcileObservability({
|
|
465
|
+
vaultBase,
|
|
466
|
+
sessionId: transcript.sessionId,
|
|
467
|
+
sessionPath,
|
|
468
|
+
transcriptPath: transcript.path,
|
|
469
|
+
dryRun,
|
|
470
|
+
});
|
|
471
|
+
} catch {
|
|
472
|
+
result = degradedObservability();
|
|
473
|
+
}
|
|
474
|
+
const observability = sanitizeReportedObservability(result);
|
|
475
|
+
if (observability.status === 'fresh' || observability.status === 'unchanged') {
|
|
476
|
+
report.observabilityFresh++;
|
|
477
|
+
} else if (observability.status === 'published' || observability.status === 'would-reconcile') {
|
|
478
|
+
report.observabilityReconciled++;
|
|
479
|
+
} else {
|
|
480
|
+
report.observabilityDegraded++;
|
|
481
|
+
}
|
|
482
|
+
return observability;
|
|
483
|
+
};
|
|
359
484
|
|
|
360
485
|
let done = 0;
|
|
361
486
|
for (const t of transcripts) {
|
|
@@ -390,7 +515,14 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
390
515
|
if (existingNote) {
|
|
391
516
|
const have = noteTurnIds(existingNote);
|
|
392
517
|
const missing = turns.filter((turn) => !have.has(String(turn.turnId)));
|
|
393
|
-
if (!missing.length) {
|
|
518
|
+
if (!missing.length) {
|
|
519
|
+
const observability = reconcileExistingObservability(t, existingNote, 0);
|
|
520
|
+
if (observability) report.sessions.push({
|
|
521
|
+
sessionId: t.sessionId, turns: 0, observability,
|
|
522
|
+
});
|
|
523
|
+
report.skipped++;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
394
526
|
if (dryRun) {
|
|
395
527
|
report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true, dryRun: true });
|
|
396
528
|
report.repaired++;
|
|
@@ -404,10 +536,13 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
404
536
|
turn.turnId, tx, vaultBase,
|
|
405
537
|
);
|
|
406
538
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
539
|
+
const observability = reconcileExistingObservability(t, existingNote, missing.length);
|
|
540
|
+
report.sessions.push({
|
|
541
|
+
sessionId: t.sessionId,
|
|
542
|
+
turns: missing.length,
|
|
543
|
+
repaired: true,
|
|
544
|
+
...(observability ? { observability } : {}),
|
|
545
|
+
});
|
|
411
546
|
report.repaired++;
|
|
412
547
|
done++;
|
|
413
548
|
} catch (error) {
|
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_OPERATING_PROFILE,
|
|
6
|
+
evaluateTaskOperatingProfileLease,
|
|
6
7
|
normalizeOperatingProfile,
|
|
7
8
|
operatingProfilePolicy,
|
|
8
9
|
resolveOperatingProfile,
|
|
@@ -68,6 +69,22 @@ function sessionOverride(entry) {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function nonNegativeSequence(value, fallback = null) {
|
|
73
|
+
const parsed = Number(value);
|
|
74
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function taskLeaseContext(entry, input, sessionId) {
|
|
78
|
+
return {
|
|
79
|
+
sessionId,
|
|
80
|
+
turnId: input?.turn_id || input?.turnId || entry?.last_prompt_turn_id || '',
|
|
81
|
+
turnSequence: nonNegativeSequence(
|
|
82
|
+
input?.turn_sequence ?? input?.turnSequence,
|
|
83
|
+
nonNegativeSequence(entry?.last_turn_sequence),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
function canonicalPath(value) {
|
|
72
89
|
const path = resolve(value).replaceAll('\\', '/');
|
|
73
90
|
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
@@ -105,7 +122,8 @@ function matchingProjectBinding(vaultResolution, input) {
|
|
|
105
122
|
}
|
|
106
123
|
}
|
|
107
124
|
|
|
108
|
-
// Resolution precedence for hooks:
|
|
125
|
+
// Resolution precedence for hooks: active request lease -> explicit session override
|
|
126
|
+
// -> project binding -> GOVERN.
|
|
109
127
|
// Binding corruption is never interpreted as OFF: an authoritative Vault keeps the Keep Core
|
|
110
128
|
// alive under GOVERN and carries a visible diagnostic to each entrypoint.
|
|
111
129
|
export function resolveHookOperatingProfile({
|
|
@@ -134,7 +152,20 @@ export function resolveHookOperatingProfile({
|
|
|
134
152
|
const entry = identity.state === 'resolved'
|
|
135
153
|
? readSessionRegistry(vaultResolution.base).sessions?.[identity.canonicalConversationId] || null
|
|
136
154
|
: null;
|
|
137
|
-
const
|
|
155
|
+
const base = sessionOverride(entry) || project;
|
|
156
|
+
const taskLease = evaluateTaskOperatingProfileLease(
|
|
157
|
+
entry?.operating_profile_task,
|
|
158
|
+
taskLeaseContext(entry, input, identity.canonicalConversationId || ''),
|
|
159
|
+
);
|
|
160
|
+
const selected = taskLease.state === 'active'
|
|
161
|
+
? {
|
|
162
|
+
profile: taskLease.profile,
|
|
163
|
+
source: 'task-lease',
|
|
164
|
+
valid: true,
|
|
165
|
+
configured: true,
|
|
166
|
+
raw: taskLease.profile,
|
|
167
|
+
}
|
|
168
|
+
: base;
|
|
138
169
|
return {
|
|
139
170
|
...selected,
|
|
140
171
|
policy: operatingProfilePolicy(selected.profile),
|
|
@@ -142,6 +173,9 @@ export function resolveHookOperatingProfile({
|
|
|
142
173
|
projectRoot: vaultResolution.projectRoot,
|
|
143
174
|
identity,
|
|
144
175
|
entry,
|
|
176
|
+
baseProfile: base.profile,
|
|
177
|
+
baseSource: base.source,
|
|
178
|
+
taskLease,
|
|
145
179
|
resolution: vaultResolution,
|
|
146
180
|
...(bindingError ? { bindingError } : {}),
|
|
147
181
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createTaskOperatingProfileLease,
|
|
5
|
+
} from '../src/operating-profile.mjs';
|
|
6
|
+
import { mutateSessionRegistry } from './obsidian-common.mjs';
|
|
7
|
+
|
|
8
|
+
function isoTimestamp(now) {
|
|
9
|
+
if (typeof now === 'string') return now;
|
|
10
|
+
if (now instanceof Date) return now.toISOString();
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function missingSessionError(sessionId) {
|
|
15
|
+
const error = new Error(`sessão não encontrada: ${sessionId}`);
|
|
16
|
+
error.code = 'WENDKEEP_SESSION_NOT_FOUND';
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setSessionTaskOperatingProfile(vaultBase, sessionId, profile, {
|
|
21
|
+
reason,
|
|
22
|
+
leaseId = randomUUID(),
|
|
23
|
+
now,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const issuedAt = isoTimestamp(now);
|
|
26
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
27
|
+
const sessions = registry.sessions || (registry.sessions = {});
|
|
28
|
+
if (!Object.hasOwn(sessions, sessionId)) throw missingSessionError(sessionId);
|
|
29
|
+
const current = sessions[sessionId];
|
|
30
|
+
const turnId = typeof current.last_prompt_turn_id === 'string'
|
|
31
|
+
? current.last_prompt_turn_id.trim()
|
|
32
|
+
: '';
|
|
33
|
+
const hasRegisteredTurn = Boolean(
|
|
34
|
+
turnId
|
|
35
|
+
&& current.turn_sequences
|
|
36
|
+
&& Object.hasOwn(current.turn_sequences, turnId)
|
|
37
|
+
&& current.turn_sequences[turnId] === current.last_turn_sequence
|
|
38
|
+
);
|
|
39
|
+
const lease = createTaskOperatingProfileLease({
|
|
40
|
+
profile,
|
|
41
|
+
reason,
|
|
42
|
+
sessionId,
|
|
43
|
+
turnId,
|
|
44
|
+
turnSequence: hasRegisteredTurn ? current.last_turn_sequence : undefined,
|
|
45
|
+
leaseId,
|
|
46
|
+
issuedAt,
|
|
47
|
+
});
|
|
48
|
+
sessions[sessionId] = {
|
|
49
|
+
...current,
|
|
50
|
+
operating_profile_task: lease,
|
|
51
|
+
updated_at: issuedAt,
|
|
52
|
+
};
|
|
53
|
+
return lease;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function consumeSessionTaskOperatingProfile(vaultBase, sessionId, leaseId, {
|
|
58
|
+
now,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (!sessionId || !leaseId) return false;
|
|
61
|
+
const consumedAt = isoTimestamp(now);
|
|
62
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
63
|
+
const current = registry.sessions?.[sessionId];
|
|
64
|
+
const lease = current?.operating_profile_task;
|
|
65
|
+
if (!lease || lease.state !== 'active' || lease.lease_id !== leaseId) return false;
|
|
66
|
+
registry.sessions[sessionId] = {
|
|
67
|
+
...current,
|
|
68
|
+
operating_profile_task: {
|
|
69
|
+
...lease,
|
|
70
|
+
state: 'consumed',
|
|
71
|
+
consumed_at: consumedAt,
|
|
72
|
+
},
|
|
73
|
+
updated_at: consumedAt,
|
|
74
|
+
};
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -5,14 +5,49 @@ import {
|
|
|
5
5
|
inspectTranscriptIdentityContent,
|
|
6
6
|
resolveSessionIdentitySnapshot,
|
|
7
7
|
} from '../packages/integrations/src/session-identity.mjs';
|
|
8
|
+
import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
|
|
9
|
+
|
|
10
|
+
function unknownTranscriptIdentity() {
|
|
11
|
+
return {
|
|
12
|
+
transcriptProvider: 'unknown',
|
|
13
|
+
provider: 'unknown',
|
|
14
|
+
canonicalConversationId: '',
|
|
15
|
+
transcriptId: '',
|
|
16
|
+
parentConversationId: '',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inspectCodexMeta(meta, fallbackTranscriptId) {
|
|
21
|
+
return {
|
|
22
|
+
transcriptProvider: 'openai',
|
|
23
|
+
provider: 'codex',
|
|
24
|
+
canonicalConversationId: meta.session_id || meta.id || '',
|
|
25
|
+
transcriptId: meta.id || fallbackTranscriptId,
|
|
26
|
+
parentConversationId: meta.parent_thread_id || meta.forked_from_id || '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withoutCodexSessionMeta(content) {
|
|
31
|
+
return String(content || '').split('\n').filter((line) => {
|
|
32
|
+
try { return JSON.parse(line)?.type !== 'session_meta'; } catch { return true; }
|
|
33
|
+
}).join('\n');
|
|
34
|
+
}
|
|
8
35
|
|
|
9
36
|
export function inspectTranscriptIdentity(transcriptPath) {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
37
|
+
const fallbackTranscriptId = transcriptPath ? basename(transcriptPath, '.jsonl') : '';
|
|
38
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return unknownTranscriptIdentity();
|
|
39
|
+
|
|
40
|
+
const codexMeta = readCodexRolloutMeta(transcriptPath);
|
|
41
|
+
if (codexMeta.ok) return inspectCodexMeta(codexMeta.meta, fallbackTranscriptId);
|
|
42
|
+
|
|
43
|
+
// Claude JSONL has no session_meta header. Preserve its existing full-content inspection,
|
|
44
|
+
// but never reinterpret a later/misplaced Codex meta as the file identity.
|
|
45
|
+
let content;
|
|
46
|
+
try { content = readFileSync(transcriptPath, 'utf-8'); } catch { return unknownTranscriptIdentity(); }
|
|
47
|
+
const inspected = inspectTranscriptIdentityContent(withoutCodexSessionMeta(content), {
|
|
48
|
+
fallbackTranscriptId,
|
|
15
49
|
});
|
|
50
|
+
return inspected.provider === 'claude' ? inspected : unknownTranscriptIdentity();
|
|
16
51
|
}
|
|
17
52
|
|
|
18
53
|
export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
|