wendkeep 0.66.3 → 0.66.5

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.
@@ -8,8 +8,13 @@ import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normali
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
9
  import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
- import { updateSessionObservability } from './session-observability.mjs';
11
+ import { materializeSessionObservability } from './session-observability.mjs';
12
12
  import { resolveSessionEntry } from './session-identity.mjs';
13
+ import { resolveObservabilityRoots } from './session-observability-lifecycle.mjs';
14
+ import {
15
+ markObservabilityCheckpoint,
16
+ readObservabilityStore,
17
+ } from './session-observability-store.mjs';
13
18
  import { mutateSessionNote } from './session-note-io.mjs';
14
19
  import { applyDerivedSections, provenanceSessions } from './derived-sections.mjs';
15
20
  import { buildSessionMemoryEvents, collectLifecycleEvidence } from './memory-handoff.mjs';
@@ -794,7 +799,206 @@ export function shouldAbortStopAfterStaging(causalStop, memoryAttempt) {
794
799
  );
795
800
  }
796
801
 
797
- export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
802
+ const STOP_OBSERVABILITY_DEADLINE_MS = 45_000;
803
+
804
+ function stopEntryCausalSnapshot(entry) {
805
+ const activationId = String(entry?.active_activation_id || '');
806
+ const activation = entry?.activations?.[activationId] || {};
807
+ return {
808
+ activationId,
809
+ activationEpoch: Number(activation.epoch || entry?.activation_epoch || 0),
810
+ turnSequence: Number(entry?.last_turn_sequence || activation.last_turn_sequence || 0),
811
+ };
812
+ }
813
+
814
+ function expectedStopCausalSnapshot(entry, causalStop, turnSequence) {
815
+ if (!causalStop) return stopEntryCausalSnapshot(entry);
816
+ return {
817
+ activationId: String(causalStop.activationId || ''),
818
+ activationEpoch: Number(causalStop.activation?.epoch || entry?.activation_epoch || 0),
819
+ turnSequence: Number(turnSequence || 0),
820
+ };
821
+ }
822
+
823
+ function sameStopCausalSnapshot(left, right) {
824
+ return left.activationId === right.activationId
825
+ && left.activationEpoch === right.activationEpoch
826
+ && left.turnSequence === right.turnSequence;
827
+ }
828
+
829
+ function stopClaudeRoots(entry) {
830
+ const paths = new Set();
831
+ const add = (value) => {
832
+ if (typeof value === 'string' && value.trim()) paths.add(value.trim());
833
+ };
834
+ add(entry?.transcript_path);
835
+ for (const path of entry?.transcript_paths || []) add(path);
836
+ for (const activation of Object.values(entry?.activations || {})) {
837
+ add(activation?.transcript_path);
838
+ for (const path of activation?.transcript_paths || []) add(path);
839
+ }
840
+ return { state: 'complete', rootPaths: [...paths], descendantPaths: [], diagnostics: [] };
841
+ }
842
+
843
+ function materializeStopObservability(request) {
844
+ return materializeSessionObservability({
845
+ vaultBase: request.vaultBase,
846
+ sessionPath: request.sessionPath,
847
+ transcriptPath: request.transcriptPath,
848
+ entry: request.entry,
849
+ canonicalConversationId: request.canonicalConversationId,
850
+ frontier: request.frontier,
851
+ signals: request.signals,
852
+ cache: request.cache,
853
+ mode: 'live',
854
+ deadlineAt: request.deadlineAt,
855
+ now: request.now,
856
+ allowNone: request.allowNone,
857
+ readRuntimeFrontier: request.readRuntimeFrontier,
858
+ withPublicationGuard: request.withPublicationGuard,
859
+ writeRegistryCheckpoint: request.writeRegistryCheckpoint,
860
+ });
861
+ }
862
+
863
+ export async function refreshStopObservability({
864
+ vaultBase,
865
+ input,
866
+ sessionPath,
867
+ sessionId,
868
+ entry,
869
+ causalStop,
870
+ turnSequence,
871
+ hookStartedAt,
872
+ expectedSignalSequence,
873
+ }, {
874
+ now = Date.now,
875
+ resolveEntry = resolveSessionEntry,
876
+ mutateRegistry = mutateSessionRegistry,
877
+ readStore = readObservabilityStore,
878
+ resolveRoots = resolveObservabilityRoots,
879
+ materialize = materializeStopObservability,
880
+ } = {}) {
881
+ if (causalStop && !causalStop.canPromoteMemory) return false;
882
+ const deadlineAt = hookStartedAt + STOP_OBSERVABILITY_DEADLINE_MS;
883
+ if (now() >= deadlineAt) return false;
884
+
885
+ const expected = expectedStopCausalSnapshot(entry, causalStop, turnSequence);
886
+ const fresh = resolveEntry(vaultBase, input, entry?.provider);
887
+ if (fresh.identity?.state !== 'resolved'
888
+ || fresh.identity.canonicalConversationId !== sessionId
889
+ || !fresh.entry?.session_file
890
+ || fresh.entry.session_file !== entry?.session_file
891
+ || !sameStopCausalSnapshot(expected, stopEntryCausalSnapshot(fresh.entry))) return false;
892
+
893
+ const runtime = readStore(vaultBase, sessionId);
894
+ const signalSequence = Number(runtime?.observability_signal_sequence || 0);
895
+ if (expectedSignalSequence !== undefined
896
+ && signalSequence !== Number(expectedSignalSequence)) return false;
897
+ if (now() >= deadlineAt) return false;
898
+
899
+ const roots = fresh.identity.provider === 'codex'
900
+ ? resolveRoots(fresh.entry)
901
+ : stopClaudeRoots(fresh.entry);
902
+ if (roots?.state !== 'complete' || !roots.rootPaths?.length) return false;
903
+
904
+ const frontier = {
905
+ canonical_session_id: sessionId,
906
+ activation_id: expected.activationId || 'legacy',
907
+ activation_epoch: expected.activationEpoch,
908
+ turn_sequence: expected.turnSequence,
909
+ signal_sequence: signalSequence,
910
+ roots_stat_hash: 'pending',
911
+ graph_cursor: 'pending',
912
+ source_manifest_hash: 'pending',
913
+ };
914
+ const readRuntimeFrontier = (candidateFrontier, guardContext) => {
915
+ const currentEntry = guardContext?.entry;
916
+ const current = currentEntry
917
+ ? { identity: { state: 'resolved', canonicalConversationId: sessionId }, entry: currentEntry }
918
+ : resolveEntry(vaultBase, input, entry?.provider);
919
+ if (current.identity?.state !== 'resolved'
920
+ || current.identity.canonicalConversationId !== sessionId
921
+ || !current.entry) {
922
+ return { ...candidateFrontier, canonical_session_id: 'unresolved' };
923
+ }
924
+ const currentCausal = stopEntryCausalSnapshot(current.entry);
925
+ const currentRuntime = readStore(vaultBase, sessionId);
926
+ return {
927
+ ...candidateFrontier,
928
+ activation_id: currentCausal.activationId || 'legacy',
929
+ activation_epoch: currentCausal.activationEpoch,
930
+ turn_sequence: currentCausal.turnSequence,
931
+ signal_sequence: Math.max(
932
+ Number(currentRuntime?.observability_signal_sequence || 0),
933
+ Number(current.entry.observability_signal_sequence || 0),
934
+ ),
935
+ };
936
+ };
937
+ const withPublicationGuard = (_candidateFrontier, publishGuarded) => (
938
+ mutateRegistry(vaultBase, (registry) => publishGuarded({
939
+ registry,
940
+ entry: registry.sessions?.[sessionId] || null,
941
+ }))
942
+ );
943
+ const writeRegistryCheckpoint = ({
944
+ frontier: checkpointFrontier,
945
+ state,
946
+ diagnostics,
947
+ snapshot,
948
+ }, guardContext) => {
949
+ const registry = guardContext?.registry;
950
+ const current = registry?.sessions?.[sessionId];
951
+ if (!current) return null;
952
+ const currentSignal = Number(current.observability_signal_sequence || checkpointFrontier.signal_sequence);
953
+ registry.sessions[sessionId] = {
954
+ ...current,
955
+ observability_signal_sequence: currentSignal,
956
+ observability_checkpoint_sequence: checkpointFrontier.signal_sequence,
957
+ observability_dirty: currentSignal > checkpointFrontier.signal_sequence,
958
+ observability_checkpoint_frontier: checkpointFrontier,
959
+ subagents_observability_state: state,
960
+ subagents_diagnostics: diagnostics || [],
961
+ };
962
+ return markObservabilityCheckpoint(vaultBase, sessionId, {
963
+ checkpointSequence: checkpointFrontier.signal_sequence,
964
+ frontier: checkpointFrontier,
965
+ sourceManifest: snapshot?.subagents?.sourceManifest,
966
+ graphCache: snapshot?.subagents?.cache,
967
+ diagnostics,
968
+ });
969
+ };
970
+
971
+ const result = await Promise.resolve(materialize({
972
+ vaultBase,
973
+ sessionPath,
974
+ entry: fresh.entry,
975
+ rootPaths: roots.rootPaths,
976
+ transcriptPath: roots.rootPaths[0],
977
+ caller: 'stop',
978
+ canonicalConversationId: sessionId,
979
+ activationId: expected.activationId,
980
+ activationEpoch: expected.activationEpoch,
981
+ turnSequence: expected.turnSequence,
982
+ signalSequence,
983
+ deadlineAt,
984
+ allowNone: true,
985
+ frontier,
986
+ signals: runtime?.signals || [],
987
+ cache: runtime?.graph_cache || null,
988
+ now,
989
+ readRuntimeFrontier,
990
+ withPublicationGuard,
991
+ writeRegistryCheckpoint,
992
+ }));
993
+ return !['stale', 'conflict', 'degraded', 'missing'].includes(result?.status);
994
+ }
995
+
996
+ export async function main({
997
+ stageMemory = stageStopMemoryAttempt,
998
+ clock = Date.now,
999
+ refreshObservability = refreshStopObservability,
1000
+ } = {}) {
1001
+ const hookStartedAt = clock();
798
1002
  const input = readHookInput();
799
1003
  if (input.stop_hook_active) {
800
1004
  writeHookOutput({});
@@ -961,8 +1165,17 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
961
1165
  }
962
1166
 
963
1167
  try {
964
- updateSessionObservability({
965
- vaultBase, sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId,
1168
+ await refreshObservability({
1169
+ vaultBase,
1170
+ input,
1171
+ sessionPath,
1172
+ sessionId,
1173
+ entry,
1174
+ causalStop,
1175
+ turnSequence: stopTurnSequence,
1176
+ hookStartedAt,
1177
+ }, {
1178
+ now: clock,
966
1179
  });
967
1180
  } catch (error) {
968
1181
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
@@ -1062,7 +1275,7 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1062
1275
 
1063
1276
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1064
1277
  try {
1065
- main();
1278
+ await main();
1066
1279
  } catch (error) {
1067
1280
  process.stderr.write(`[wendkeep] Stop falhou: ${error.message}\n`);
1068
1281
  // Same reasoning as the identity bail: stderr is discarded by Codex. Exit stays 0 —
@@ -8,29 +8,283 @@
8
8
  import { existsSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { pathToFileURL } from 'url';
11
- import { readHookInput, writeHookOutput, getVaultBase, providerMeta } from './obsidian-common.mjs';
12
- import { updateSessionObservability } from './session-observability.mjs';
11
+ import {
12
+ getVaultBase,
13
+ mutateSessionRegistry,
14
+ providerMeta,
15
+ readHookInput,
16
+ writeHookOutput,
17
+ } from './obsidian-common.mjs';
18
+ import { materializeSessionObservability } from './session-observability.mjs';
13
19
  import { resolveSessionEntry } from './session-identity.mjs';
20
+ import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
21
+ import { resolveObservabilityRoots } from './session-observability-lifecycle.mjs';
22
+ import {
23
+ readObservabilityStore,
24
+ markObservabilityCheckpoint,
25
+ recordObservabilitySignal,
26
+ releaseObservabilityLease,
27
+ tryAcquireObservabilityLease,
28
+ } from './session-observability-store.mjs';
14
29
 
15
- export function refreshSubagents(vaultBase, input) {
16
- const { identity, entry } = resolveSessionEntry(vaultBase, input, providerMeta(input.provider).id);
30
+ const SUBAGENT_COALESCE_MS = 250;
31
+ const SUBAGENT_DEADLINE_MS = 15_000;
32
+
33
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
34
+
35
+ function causalSnapshot(entry) {
36
+ const activationId = String(entry?.active_activation_id || '');
37
+ const activation = entry?.activations?.[activationId] || {};
38
+ return {
39
+ activationId,
40
+ activationEpoch: Number(activation.epoch || entry?.activation_epoch || 0),
41
+ turnSequence: Number(entry?.last_turn_sequence || activation.last_turn_sequence || 0),
42
+ };
43
+ }
44
+
45
+ function sameCausalSnapshot(left, right) {
46
+ return left.activationId === right.activationId
47
+ && left.activationEpoch === right.activationEpoch
48
+ && left.turnSequence === right.turnSequence;
49
+ }
50
+
51
+ function defaultMaterialize(request) {
52
+ return materializeSessionObservability({
53
+ vaultBase: request.vaultBase,
54
+ sessionPath: request.sessionPath,
55
+ transcriptPath: request.transcriptPath,
56
+ entry: request.entry,
57
+ canonicalConversationId: request.canonicalConversationId,
58
+ frontier: request.frontier,
59
+ signals: request.signals,
60
+ cache: request.cache,
61
+ mode: 'live',
62
+ deadlineAt: request.deadlineAt,
63
+ now: request.now,
64
+ allowNone: request.allowNone,
65
+ readRuntimeFrontier: request.readRuntimeFrontier,
66
+ withPublicationGuard: request.withPublicationGuard,
67
+ writeRegistryCheckpoint: request.writeRegistryCheckpoint,
68
+ });
69
+ }
70
+
71
+ function claudeRoots(entry) {
72
+ const paths = new Set();
73
+ const add = (value) => {
74
+ if (typeof value === 'string' && value.trim()) paths.add(value.trim());
75
+ };
76
+ add(entry?.transcript_path);
77
+ for (const path of entry?.transcript_paths || []) add(path);
78
+ for (const activation of Object.values(entry?.activations || {})) {
79
+ add(activation?.transcript_path);
80
+ for (const path of activation?.transcript_paths || []) add(path);
81
+ }
82
+ return { state: 'complete', rootPaths: [...paths], descendantPaths: [], diagnostics: [] };
83
+ }
84
+
85
+ export async function refreshSubagents(vaultBase, input, {
86
+ now = Date.now,
87
+ hookStartedAt = now(),
88
+ sleep = wait,
89
+ coalesceMs = SUBAGENT_COALESCE_MS,
90
+ deadlineMs = SUBAGENT_DEADLINE_MS,
91
+ resolveEntry = resolveSessionEntry,
92
+ readMeta = readCodexRolloutMeta,
93
+ mutateRegistry = mutateSessionRegistry,
94
+ recordSignal = recordObservabilitySignal,
95
+ readStore = readObservabilityStore,
96
+ acquireLease = tryAcquireObservabilityLease,
97
+ releaseLease = releaseObservabilityLease,
98
+ resolveRoots = resolveObservabilityRoots,
99
+ materialize = defaultMaterialize,
100
+ } = {}) {
101
+ const deadlineAt = hookStartedAt + deadlineMs;
102
+ const provider = providerMeta(input.provider).id;
103
+ const { identity, entry } = resolveEntry(vaultBase, input, provider);
17
104
  if (identity.state !== 'resolved') return false;
18
- const transcriptPath = identity.transcriptPath;
105
+ const childTranscriptPath = identity.transcriptPath;
19
106
  const sessionRel = entry?.session_file || '';
20
107
  if (!sessionRel) return false;
21
- const sessionPath = join(vaultBase, sessionRel);
22
- if (!existsSync(sessionPath)) return false;
23
- updateSessionObservability({
24
- vaultBase, sessionPath, transcriptPath, caller: 'subagent-stop',
25
- canonicalConversationId: identity.canonicalConversationId,
108
+
109
+ let childParentThreadId = '';
110
+ if (identity.provider === 'codex') {
111
+ const childMeta = readMeta(childTranscriptPath);
112
+ if (!childMeta?.ok || !childMeta.meta?.source?.subagent) return false;
113
+ if (!childMeta.meta.id || childMeta.meta.id !== identity.transcriptId) return false;
114
+ if (childMeta.meta.session_id
115
+ && childMeta.meta.session_id !== identity.canonicalConversationId) return false;
116
+ childParentThreadId = String(
117
+ childMeta.meta.parent_thread_id
118
+ || childMeta.meta.source?.subagent?.thread_spawn?.parent_thread_id
119
+ || '',
120
+ );
121
+ }
122
+
123
+ const observed = causalSnapshot(entry);
124
+ const signal = mutateRegistry(vaultBase, (registry) => {
125
+ const current = registry.sessions?.[identity.canonicalConversationId];
126
+ if (!current || current.session_file !== sessionRel
127
+ || !sameCausalSnapshot(observed, causalSnapshot(current))) return null;
128
+ const recorded = recordSignal(vaultBase, identity.canonicalConversationId, {
129
+ rollout_id: identity.transcriptId,
130
+ transcript_path: childTranscriptPath,
131
+ parent_thread_id: childParentThreadId,
132
+ kind: 'started',
133
+ activation_id: observed.activationId,
134
+ activation_epoch: observed.activationEpoch,
135
+ turn_sequence: observed.turnSequence,
136
+ });
137
+ if (!recorded?.state) return recorded;
138
+ registry.sessions[identity.canonicalConversationId] = {
139
+ ...current,
140
+ observability_signal_sequence: recorded.sequence,
141
+ observability_checkpoint_sequence: Number(
142
+ recorded.state.observability_checkpoint_sequence
143
+ ?? current.observability_checkpoint_sequence
144
+ ?? 0,
145
+ ),
146
+ observability_dirty: Boolean(recorded.state.observability_dirty),
147
+ };
148
+ return recorded;
149
+ });
150
+ if (!signal?.state) return false;
151
+ if (!signal.state.observability_dirty) return true;
152
+
153
+ await sleep(coalesceMs);
154
+ if (now() >= deadlineAt) return true;
155
+
156
+ const latest = readStore(vaultBase, identity.canonicalConversationId);
157
+ if (!latest?.observability_dirty
158
+ || latest.observability_signal_sequence !== signal.sequence) return true;
159
+
160
+ const leaseNow = now();
161
+ if (leaseNow >= deadlineAt) return true;
162
+ const lease = acquireLease(vaultBase, identity.canonicalConversationId, {
163
+ signalSequence: signal.sequence,
164
+ now: leaseNow,
165
+ ttlMs: Math.max(1, deadlineAt - leaseNow),
26
166
  });
27
- return true;
167
+ if (!lease?.acquired) return true;
168
+
169
+ try {
170
+ if (now() >= deadlineAt) return true;
171
+ const fresh = resolveEntry(vaultBase, input, provider);
172
+ if (fresh.identity?.state !== 'resolved'
173
+ || fresh.identity.canonicalConversationId !== identity.canonicalConversationId
174
+ || !fresh.entry?.session_file
175
+ || !sameCausalSnapshot(observed, causalSnapshot(fresh.entry))) return true;
176
+
177
+ const sessionPath = join(vaultBase, fresh.entry.session_file);
178
+ if (!existsSync(sessionPath)) return true;
179
+ const roots = identity.provider === 'codex'
180
+ ? resolveRoots(fresh.entry)
181
+ : claudeRoots(fresh.entry);
182
+ if (roots?.state !== 'complete' || !roots.rootPaths?.length) return true;
183
+
184
+ const runtimeState = lease.state || latest;
185
+ const frontier = {
186
+ canonical_session_id: identity.canonicalConversationId,
187
+ activation_id: observed.activationId || 'legacy',
188
+ activation_epoch: observed.activationEpoch,
189
+ turn_sequence: observed.turnSequence,
190
+ signal_sequence: signal.sequence,
191
+ roots_stat_hash: 'pending',
192
+ graph_cursor: 'pending',
193
+ source_manifest_hash: 'pending',
194
+ };
195
+ const readRuntimeFrontier = (candidateFrontier, guardContext) => {
196
+ const currentRuntime = readStore(vaultBase, identity.canonicalConversationId);
197
+ const currentEntry = guardContext?.entry;
198
+ const currentResolved = currentEntry
199
+ ? { identity: { state: 'resolved', canonicalConversationId: identity.canonicalConversationId }, entry: currentEntry }
200
+ : resolveEntry(vaultBase, input, provider);
201
+ if (currentResolved.identity?.state !== 'resolved'
202
+ || currentResolved.identity.canonicalConversationId !== identity.canonicalConversationId
203
+ || !currentResolved.entry) {
204
+ return { ...candidateFrontier, canonical_session_id: 'unresolved' };
205
+ }
206
+ const currentCausal = causalSnapshot(currentResolved.entry);
207
+ return {
208
+ ...candidateFrontier,
209
+ activation_id: currentCausal.activationId || 'legacy',
210
+ activation_epoch: currentCausal.activationEpoch,
211
+ turn_sequence: currentCausal.turnSequence,
212
+ signal_sequence: Math.max(
213
+ Number(currentRuntime?.observability_signal_sequence || 0),
214
+ Number(currentResolved.entry.observability_signal_sequence || 0),
215
+ ),
216
+ };
217
+ };
218
+ const withPublicationGuard = (_candidateFrontier, publishGuarded) => (
219
+ mutateRegistry(vaultBase, (registry) => publishGuarded({
220
+ registry,
221
+ entry: registry.sessions?.[identity.canonicalConversationId] || null,
222
+ }))
223
+ );
224
+ const writeRegistryCheckpoint = ({
225
+ frontier: checkpointFrontier,
226
+ state,
227
+ diagnostics,
228
+ snapshot,
229
+ }, guardContext) => {
230
+ const registry = guardContext?.registry;
231
+ const current = registry?.sessions?.[identity.canonicalConversationId];
232
+ if (!current) return null;
233
+ const currentSignal = Number(current.observability_signal_sequence || checkpointFrontier.signal_sequence);
234
+ registry.sessions[identity.canonicalConversationId] = {
235
+ ...current,
236
+ observability_signal_sequence: currentSignal,
237
+ observability_checkpoint_sequence: checkpointFrontier.signal_sequence,
238
+ observability_dirty: currentSignal > checkpointFrontier.signal_sequence,
239
+ observability_checkpoint_frontier: checkpointFrontier,
240
+ subagents_observability_state: state,
241
+ subagents_diagnostics: diagnostics || [],
242
+ };
243
+ return markObservabilityCheckpoint(vaultBase, identity.canonicalConversationId, {
244
+ checkpointSequence: checkpointFrontier.signal_sequence,
245
+ frontier: checkpointFrontier,
246
+ sourceManifest: snapshot?.subagents?.sourceManifest,
247
+ graphCache: snapshot?.subagents?.cache,
248
+ diagnostics,
249
+ });
250
+ };
251
+
252
+ await Promise.resolve(materialize({
253
+ vaultBase,
254
+ sessionPath,
255
+ entry: fresh.entry,
256
+ rootPaths: roots.rootPaths,
257
+ transcriptPath: roots.rootPaths[0],
258
+ caller: 'subagent-stop',
259
+ canonicalConversationId: identity.canonicalConversationId,
260
+ activationId: observed.activationId,
261
+ activationEpoch: observed.activationEpoch,
262
+ turnSequence: observed.turnSequence,
263
+ signalSequence: signal.sequence,
264
+ deadlineAt,
265
+ allowNone: false,
266
+ frontier,
267
+ signals: runtimeState.signals || [],
268
+ cache: runtimeState.graph_cache || null,
269
+ now,
270
+ readRuntimeFrontier,
271
+ withPublicationGuard,
272
+ writeRegistryCheckpoint,
273
+ }));
274
+ return true;
275
+ } finally {
276
+ releaseLease(vaultBase, identity.canonicalConversationId, {
277
+ ownerToken: lease.ownerToken,
278
+ signalSequence: signal.sequence,
279
+ });
280
+ }
28
281
  }
29
282
 
30
283
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
31
284
  try {
285
+ const hookStartedAt = Date.now();
32
286
  const input = readHookInput();
33
- refreshSubagents(getVaultBase(input), input);
287
+ await refreshSubagents(getVaultBase(input), input, { hookStartedAt });
34
288
  writeHookOutput({});
35
289
  } catch (error) {
36
290
  process.stderr.write(`[wendkeep] subagent-stop falhou: ${error.message}\n`);
@@ -29,6 +29,49 @@ function walkAgentJsonl(dir) {
29
29
  return out;
30
30
  }
31
31
 
32
+ function inspectAgentJsonlDirectory(dir) {
33
+ let names;
34
+ try {
35
+ names = readdirSync(dir);
36
+ } catch (error) {
37
+ return { state: error?.code === 'ENOENT' ? 'absent' : 'error', files: [] };
38
+ }
39
+ const files = [];
40
+ for (const name of names) {
41
+ const path = join(dir, name);
42
+ let stat;
43
+ try {
44
+ stat = statSync(path);
45
+ } catch {
46
+ return { state: 'error', files: [] };
47
+ }
48
+ if (stat.isDirectory()) {
49
+ const nested = inspectAgentJsonlDirectory(path);
50
+ if (nested.state === 'error' || nested.state === 'absent') return { state: 'error', files: [] };
51
+ files.push(...nested.files);
52
+ } else if (name.startsWith('agent-') && name.endsWith('.jsonl')) {
53
+ files.push(path);
54
+ }
55
+ }
56
+ return { state: 'ok', files };
57
+ }
58
+
59
+ function validJsonl(path) {
60
+ let lines;
61
+ try {
62
+ lines = readFileSync(path, 'utf8').split(/\r?\n/).filter((line) => line.trim());
63
+ } catch {
64
+ return false;
65
+ }
66
+ if (!lines.length) return false;
67
+ try {
68
+ for (const line of lines) JSON.parse(line);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
32
75
  // workflows/scripts/<name>-wf_<rid>.js -> { wf_<rid>: <name> }
33
76
  function workflowNameMap(sessionDir) {
34
77
  const map = {};
@@ -276,6 +319,28 @@ export function collectSubagentUsage(sessionDir) {
276
319
  };
277
320
  }
278
321
 
322
+ // Schema-2 callers need to distinguish proven absence from read/parse failure. Keep the
323
+ // legacy aggregate API above intact while exposing a fail-closed state for observability.
324
+ export function collectClaudeSubagentUsageState(sessionDir) {
325
+ const scan = inspectAgentJsonlDirectory(join(sessionDir, 'subagents'));
326
+ if (scan.state === 'absent' || (scan.state === 'ok' && scan.files.length === 0)) {
327
+ return { state: 'none', diagnostics: [] };
328
+ }
329
+ if (scan.state !== 'ok' || scan.files.some((path) => !validJsonl(path))) {
330
+ return {
331
+ state: 'degraded',
332
+ diagnostics: [{ code: 'CHILD_META_INVALID', count: 1 }],
333
+ };
334
+ }
335
+ const collected = collectSubagentUsage(sessionDir);
336
+ return collected
337
+ ? { ...collected, state: 'complete', diagnostics: [] }
338
+ : {
339
+ state: 'degraded',
340
+ diagnostics: [{ code: 'CHILD_META_INVALID', count: 1 }],
341
+ };
342
+ }
343
+
279
344
  function workflowLine(w) {
280
345
  const parts = [w.runId];
281
346
  if (w.status) parts.push(w.status);