@aaac/observability 0.1.13 → 0.1.15
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 +33 -0
- package/dist/{chunk-RUVM5AAO.js → chunk-3DXZNA3E.js} +1024 -15
- package/dist/chunk-3DXZNA3E.js.map +1 -0
- package/dist/chunk-EKFRH7PX.js +266 -0
- package/dist/chunk-EKFRH7PX.js.map +1 -0
- package/dist/cli/global-record.d.ts +53 -0
- package/dist/cli/global-record.js +239 -0
- package/dist/cli/global-record.js.map +1 -0
- package/dist/cli/index.js +330 -210
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +385 -6
- package/dist/index.js +43 -3
- package/package.json +3 -2
- package/dist/chunk-RUVM5AAO.js.map +0 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DB_PATH,
|
|
3
|
+
captureWorktree,
|
|
4
|
+
createPipeline,
|
|
5
|
+
emitHumanInstruction,
|
|
6
|
+
emitPromotionPr,
|
|
7
|
+
emitQualityGateResult,
|
|
8
|
+
evaluateMapping,
|
|
9
|
+
generateId,
|
|
10
|
+
loadEventMappingConfig,
|
|
11
|
+
worktreeAttributes
|
|
12
|
+
} from "./chunk-3DXZNA3E.js";
|
|
13
|
+
|
|
14
|
+
// src/cli/record-hook.ts
|
|
15
|
+
import { mkdir, rename, unlink, writeFile } from "fs/promises";
|
|
16
|
+
import { dirname, join } from "path";
|
|
17
|
+
var GIT_CONTEXT_COMMAND_RE = /\bgit\s+(?:commit|push|merge)\b/;
|
|
18
|
+
var WORKTREE_CAPTURE_HOOKS = /* @__PURE__ */ new Set([
|
|
19
|
+
"afterfileedit",
|
|
20
|
+
"posttooluse",
|
|
21
|
+
"beforeshellexecution",
|
|
22
|
+
"sessionstart",
|
|
23
|
+
"sessionend",
|
|
24
|
+
"start",
|
|
25
|
+
"stop"
|
|
26
|
+
]);
|
|
27
|
+
var GIT_HOOK_NAMES = /* @__PURE__ */ new Set([
|
|
28
|
+
"pre-commit",
|
|
29
|
+
"post-commit",
|
|
30
|
+
"pre-push",
|
|
31
|
+
"post-merge",
|
|
32
|
+
"post-checkout"
|
|
33
|
+
]);
|
|
34
|
+
function defaultMappingConfigPath(dbPath) {
|
|
35
|
+
return join(dirname(dbPath), "config", "event-mapping.json");
|
|
36
|
+
}
|
|
37
|
+
function contextFilePath(dbPath) {
|
|
38
|
+
return join(dirname(dbPath), ".observ-context.json");
|
|
39
|
+
}
|
|
40
|
+
async function readStdin() {
|
|
41
|
+
if (process.stdin.isTTY) return "";
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let data = "";
|
|
44
|
+
let settled = false;
|
|
45
|
+
const finish = () => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
resolve(data);
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
process.stdin.setEncoding("utf8");
|
|
52
|
+
process.stdin.on("data", (chunk) => {
|
|
53
|
+
data += chunk;
|
|
54
|
+
});
|
|
55
|
+
process.stdin.on("end", finish);
|
|
56
|
+
process.stdin.on("error", finish);
|
|
57
|
+
process.stdin.on("close", finish);
|
|
58
|
+
const t = setTimeout(finish, 1e3);
|
|
59
|
+
if (typeof t.unref === "function") t.unref();
|
|
60
|
+
} catch {
|
|
61
|
+
finish();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function parseHookInput(raw) {
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
if (!trimmed) return {};
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(trimmed);
|
|
70
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function asString(value) {
|
|
76
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
77
|
+
}
|
|
78
|
+
function asNumber(value) {
|
|
79
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
80
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
81
|
+
const n = Number(value);
|
|
82
|
+
if (Number.isFinite(n)) return n;
|
|
83
|
+
}
|
|
84
|
+
return void 0;
|
|
85
|
+
}
|
|
86
|
+
function emitMappedSpans(collector, hook, rule, hookInput, sessionId, source, extraAttributes) {
|
|
87
|
+
const { spans, links } = evaluateMapping(rule, hookInput);
|
|
88
|
+
const linksBySpan = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const link of links) {
|
|
90
|
+
const arr = linksBySpan.get(link.fromSpanId) ?? [];
|
|
91
|
+
arr.push({ targetSpanId: link.toSpanId, linkType: link.linkType });
|
|
92
|
+
linksBySpan.set(link.fromSpanId, arr);
|
|
93
|
+
}
|
|
94
|
+
const parentConversationId = hook === "subagentStart" ? asString(hookInput.parent_conversation_id) : void 0;
|
|
95
|
+
for (const span of spans) {
|
|
96
|
+
const attributes = { ...span.attributes };
|
|
97
|
+
if (sessionId) attributes["session_id"] = sessionId;
|
|
98
|
+
if (parentConversationId) {
|
|
99
|
+
attributes["parent_conversation_id"] = parentConversationId;
|
|
100
|
+
}
|
|
101
|
+
if (extraAttributes) Object.assign(attributes, extraAttributes);
|
|
102
|
+
collector.emit({
|
|
103
|
+
source,
|
|
104
|
+
eventType: span.eventType,
|
|
105
|
+
lifecycle: span.lifecycle,
|
|
106
|
+
spanId: span.spanId,
|
|
107
|
+
parentSpanId: span.parentSpanId,
|
|
108
|
+
attributes,
|
|
109
|
+
links: linksBySpan.get(span.spanId) ?? []
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return spans.length;
|
|
113
|
+
}
|
|
114
|
+
function emitWorktree(collector, hook, cwd, sessionId, source, git, extraAttributes) {
|
|
115
|
+
if (!WORKTREE_CAPTURE_HOOKS.has(hook.toLowerCase())) return 0;
|
|
116
|
+
const snapshot = git ? captureWorktree(cwd, git) : captureWorktree(cwd);
|
|
117
|
+
if (!snapshot) return 0;
|
|
118
|
+
const attributes = worktreeAttributes(snapshot, sessionId || void 0);
|
|
119
|
+
if (extraAttributes) Object.assign(attributes, extraAttributes);
|
|
120
|
+
collector.emit({
|
|
121
|
+
source,
|
|
122
|
+
eventType: "promotion.worktree",
|
|
123
|
+
lifecycle: "instant",
|
|
124
|
+
attributes
|
|
125
|
+
});
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
function emitGenericFallback(collector, hook, sessionId, source, extraAttributes) {
|
|
129
|
+
const eventType = (GIT_HOOK_NAMES.has(hook) ? "git." : "cursor.") + hook;
|
|
130
|
+
const attributes = { "hook.name": hook };
|
|
131
|
+
if (sessionId) attributes["session_id"] = sessionId;
|
|
132
|
+
if (extraAttributes) Object.assign(attributes, extraAttributes);
|
|
133
|
+
collector.emit({
|
|
134
|
+
source,
|
|
135
|
+
eventType,
|
|
136
|
+
lifecycle: "instant",
|
|
137
|
+
attributes
|
|
138
|
+
});
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
function emitHumanEvents(collector, hook, hookInput, sessionId) {
|
|
142
|
+
let count = 0;
|
|
143
|
+
if (hook === "beforeSubmitPrompt") {
|
|
144
|
+
const prompt = asString(hookInput.prompt);
|
|
145
|
+
if (prompt !== void 0) {
|
|
146
|
+
const attachments = Array.isArray(hookInput.attachments) ? hookInput.attachments : [];
|
|
147
|
+
emitHumanInstruction(collector, { sessionId, prompt, attachments });
|
|
148
|
+
count += 1;
|
|
149
|
+
}
|
|
150
|
+
} else if (hook === "afterShellExecution") {
|
|
151
|
+
const command = asString(hookInput.command);
|
|
152
|
+
if (command !== void 0) {
|
|
153
|
+
const exitCode = asNumber(hookInput.exit_code) ?? asNumber(hookInput.exitCode) ?? 0;
|
|
154
|
+
const durationMs = asNumber(hookInput.duration);
|
|
155
|
+
if (emitQualityGateResult(collector, { sessionId, command, exitCode, durationMs })) {
|
|
156
|
+
count += 1;
|
|
157
|
+
}
|
|
158
|
+
const output = asString(hookInput.output) ?? "";
|
|
159
|
+
if (emitPromotionPr(collector, { sessionId, command, output })) {
|
|
160
|
+
count += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
async function manageContextFile(hook, hookInput, sessionId, dbPath) {
|
|
167
|
+
const command = asString(hookInput.command) ?? "";
|
|
168
|
+
const isGitContextCommand = GIT_CONTEXT_COMMAND_RE.test(command);
|
|
169
|
+
const file = contextFilePath(dbPath);
|
|
170
|
+
if (hook === "beforeShellExecution" && isGitContextCommand) {
|
|
171
|
+
await mkdir(dirname(file), { recursive: true });
|
|
172
|
+
const payload = JSON.stringify({
|
|
173
|
+
session_id: sessionId || null,
|
|
174
|
+
nonce: generateId(),
|
|
175
|
+
created_at_ms: Date.now()
|
|
176
|
+
}) + "\n";
|
|
177
|
+
const tmp = `${file}.${process.pid}.${generateId()}.tmp`;
|
|
178
|
+
await writeFile(tmp, payload, "utf8");
|
|
179
|
+
await rename(tmp, file);
|
|
180
|
+
} else if (hook === "afterShellExecution" && isGitContextCommand) {
|
|
181
|
+
await unlink(file).catch(() => {
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function runRecordHook(args) {
|
|
186
|
+
const { hook, hookInput, dbPath, mappingConfigPath } = args;
|
|
187
|
+
const source = args.source ?? "cursor-hook";
|
|
188
|
+
const sessionId = args.sessionId ?? asString(hookInput.conversation_id) ?? "";
|
|
189
|
+
const cwd = args.cwd ?? asString(hookInput.cwd) ?? process.cwd();
|
|
190
|
+
await manageContextFile(hook, hookInput, sessionId, dbPath).catch(() => {
|
|
191
|
+
});
|
|
192
|
+
let config;
|
|
193
|
+
try {
|
|
194
|
+
config = await loadEventMappingConfig(mappingConfigPath);
|
|
195
|
+
} catch {
|
|
196
|
+
config = void 0;
|
|
197
|
+
}
|
|
198
|
+
const rule = config?.mappings?.[hook];
|
|
199
|
+
let recorded = 0;
|
|
200
|
+
let fallback = false;
|
|
201
|
+
const { collector, sink } = createPipeline({ dbPath });
|
|
202
|
+
try {
|
|
203
|
+
if (rule) {
|
|
204
|
+
recorded += emitMappedSpans(
|
|
205
|
+
collector,
|
|
206
|
+
hook,
|
|
207
|
+
rule,
|
|
208
|
+
hookInput,
|
|
209
|
+
sessionId,
|
|
210
|
+
source,
|
|
211
|
+
args.extraAttributes
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
recorded += emitGenericFallback(
|
|
215
|
+
collector,
|
|
216
|
+
hook,
|
|
217
|
+
sessionId,
|
|
218
|
+
source,
|
|
219
|
+
args.extraAttributes
|
|
220
|
+
);
|
|
221
|
+
fallback = true;
|
|
222
|
+
}
|
|
223
|
+
recorded += emitHumanEvents(collector, hook, hookInput, sessionId);
|
|
224
|
+
try {
|
|
225
|
+
recorded += emitWorktree(
|
|
226
|
+
collector,
|
|
227
|
+
hook,
|
|
228
|
+
cwd,
|
|
229
|
+
sessionId,
|
|
230
|
+
source,
|
|
231
|
+
args.git,
|
|
232
|
+
args.extraAttributes
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
sink.close();
|
|
238
|
+
}
|
|
239
|
+
return { recorded, fallback };
|
|
240
|
+
}
|
|
241
|
+
async function handleRecordHook(hookName, options, _parentOpts) {
|
|
242
|
+
const hook = hookName ?? "unknown";
|
|
243
|
+
const dbPath = options.db ?? DEFAULT_DB_PATH;
|
|
244
|
+
const mappingConfigPath = options.mappingConfig ?? defaultMappingConfigPath(dbPath);
|
|
245
|
+
let result = { recorded: 0, fallback: false };
|
|
246
|
+
try {
|
|
247
|
+
const hookInput = parseHookInput(await readStdin());
|
|
248
|
+
result = await runRecordHook({ hook, hookInput, dbPath, mappingConfigPath });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
process.stderr.write(
|
|
251
|
+
`record-hook: ${err instanceof Error ? err.message : String(err)}
|
|
252
|
+
`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export {
|
|
259
|
+
defaultMappingConfigPath,
|
|
260
|
+
contextFilePath,
|
|
261
|
+
readStdin,
|
|
262
|
+
parseHookInput,
|
|
263
|
+
runRecordHook,
|
|
264
|
+
handleRecordHook
|
|
265
|
+
};
|
|
266
|
+
//# sourceMappingURL=chunk-EKFRH7PX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/record-hook.ts"],"sourcesContent":["/**\n * record-hook handler — stdin-aware Cursor/git hook recorder.\n *\n * This is the runtime entrypoint for `.cursor/hooks/observ-record.sh`. Unlike the\n * thin `record` command (scalar flags only), `record-hook`:\n *\n * 1. parses the hook JSON payload from stdin (conversation_id + hook-specific fields)\n * 2. loads `.agent-logs/config/event-mapping.json` and resolves the matching\n * mapping rule into 3-axis spans + cross-axis links (evaluateMapping)\n * 3. injects session_id = conversation_id on every emitted event (correlation key\n * with @aaac/runtime in-process events)\n * 4. emits human-interaction events (human.instruction / quality_gate.result /\n * promotion.pr) for the relevant hooks\n * 5. manages the short-lived git context file `.agent-logs/.observ-context.json`\n * (write on beforeShellExecution for git commit/push/merge, delete on\n * afterShellExecution) so git hooks can correlate (read side: #115)\n *\n * It is strictly fail-open: any parse/IO/config error degrades to the generic\n * fallback recording (or a no-op) and never throws — recording must NEVER block\n * the underlying hook operation.\n *\n * Issue #114 / shift-left-event-mapping.md §6 (option B).\n */\nimport { mkdir, rename, unlink, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport {\n createPipeline,\n generateId,\n DEFAULT_DB_PATH,\n loadEventMappingConfig,\n evaluateMapping,\n emitHumanInstruction,\n emitQualityGateResult,\n emitPromotionPr,\n} from \"../index.js\";\nimport type { EventCollector } from \"../collector/event-collector.js\";\nimport type { AttrValue, CanonicalLink } from \"../types/canonical-event.js\";\nimport type { EventMappingConfig, EventMappingRule } from \"../event-mapping/types.js\";\nimport { captureWorktree, worktreeAttributes, type GitRunner } from \"./worktree.js\";\n\n// ── Constants ──────────────────────────────────────────────────────────────────\n\n/** git subcommands that warrant propagating session context to git hooks. */\nconst GIT_CONTEXT_COMMAND_RE = /\\bgit\\s+(?:commit|push|merge)\\b/;\n\n/**\n * Hook names (case-insensitive) after which the working-tree HEAD is captured\n * (#175 / WS-B): session open/close, after file edit / tool use, before shell\n * execution. Matched against the lower-cased hook name so Cursor and Claude\n * naming variants both resolve.\n */\nconst WORKTREE_CAPTURE_HOOKS = new Set([\n \"afterfileedit\",\n \"posttooluse\",\n \"beforeshellexecution\",\n \"sessionstart\",\n \"sessionend\",\n \"start\",\n \"stop\",\n]);\n\n/** Hook names that map to git.* event types in the generic fallback. */\nconst GIT_HOOK_NAMES = new Set([\n \"pre-commit\",\n \"post-commit\",\n \"pre-push\",\n \"post-merge\",\n \"post-checkout\",\n]);\n\n// ── Options ──────────────────────────────────────────────────────────────────\n\nexport interface RecordHookOptions {\n mappingConfig?: string;\n db?: string;\n}\n\n/** Parsed result of running the hook recorder (also written to stdout as JSON). */\nexport interface RecordHookResult {\n /** Number of events emitted into the pipeline. */\n recorded: number;\n /** True when no mapping rule matched and the generic fallback was used. */\n fallback: boolean;\n}\n\n// ── Path helpers ───────────────────────────────────────────────────────────────\n\n/** Default event-mapping config path, derived from the db directory. */\nexport function defaultMappingConfigPath(dbPath: string): string {\n return join(dirname(dbPath), \"config\", \"event-mapping.json\");\n}\n\n/** Short-lived git context file path, derived from the db directory. */\nexport function contextFilePath(dbPath: string): string {\n return join(dirname(dbPath), \".observ-context.json\");\n}\n\n// ── stdin parsing ────────────────────────────────────────────────────────────\n\n/**\n * Read the entire stdin stream as a string.\n * Resolves \"\" when stdin is a TTY (no payload) or on any error — the git-hook\n * path frequently provides no stdin, which must be handled gracefully (AC-1).\n */\nexport async function readStdin(): Promise<string> {\n if (process.stdin.isTTY) return \"\";\n return new Promise<string>((resolve) => {\n let data = \"\";\n let settled = false;\n const finish = (): void => {\n if (settled) return;\n settled = true;\n resolve(data);\n };\n try {\n process.stdin.setEncoding(\"utf8\");\n process.stdin.on(\"data\", (chunk) => {\n data += chunk;\n });\n process.stdin.on(\"end\", finish);\n process.stdin.on(\"error\", finish);\n process.stdin.on(\"close\", finish);\n // Safety net: never hang the hook if stdin is left open.\n const t = setTimeout(finish, 1000);\n if (typeof t.unref === \"function\") t.unref();\n } catch {\n finish();\n }\n });\n}\n\n/** Parse a stdin payload into an object; returns {} for empty/invalid JSON. */\nexport function parseHookInput(raw: string): Record<string, unknown> {\n const trimmed = raw.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed) as unknown;\n return parsed !== null && typeof parsed === \"object\"\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n}\n\n// ── Field coercion ───────────────────────────────────────────────────────────\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\nfunction asNumber(value: unknown): number | undefined {\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n if (typeof value === \"string\" && value.trim() !== \"\") {\n const n = Number(value);\n if (Number.isFinite(n)) return n;\n }\n return undefined;\n}\n\n// ── Emission ─────────────────────────────────────────────────────────────────\n\n/**\n * Emit the 3-axis spans + cross-axis links resolved from a mapping rule.\n * session_id (and parent_conversation_id on subagentStart) are injected on every\n * span so all hook-derived events share the correlation key.\n */\nfunction emitMappedSpans(\n collector: EventCollector,\n hook: string,\n rule: EventMappingRule,\n hookInput: Record<string, unknown>,\n sessionId: string,\n source: string,\n extraAttributes?: Record<string, AttrValue>,\n): number {\n const { spans, links } = evaluateMapping(rule, hookInput);\n\n // Group resolved links by their source spanId so they can be attached to the\n // emitting (source) event as CanonicalLink entries.\n const linksBySpan = new Map<string, CanonicalLink[]>();\n for (const link of links) {\n const arr = linksBySpan.get(link.fromSpanId) ?? [];\n arr.push({ targetSpanId: link.toSpanId, linkType: link.linkType });\n linksBySpan.set(link.fromSpanId, arr);\n }\n\n const parentConversationId =\n hook === \"subagentStart\" ? asString(hookInput.parent_conversation_id) : undefined;\n\n for (const span of spans) {\n const attributes: Record<string, AttrValue> = { ...span.attributes };\n if (sessionId) attributes[\"session_id\"] = sessionId;\n if (parentConversationId) {\n attributes[\"parent_conversation_id\"] = parentConversationId;\n }\n if (extraAttributes) Object.assign(attributes, extraAttributes);\n collector.emit({\n source,\n eventType: span.eventType,\n lifecycle: span.lifecycle,\n spanId: span.spanId,\n parentSpanId: span.parentSpanId,\n attributes,\n links: linksBySpan.get(span.spanId) ?? [],\n });\n }\n\n return spans.length;\n}\n\n/**\n * Emit a `promotion.worktree` instant span capturing the working HEAD (#175).\n *\n * Best-effort + fail-open: skips silently when the cwd is not a git repo or any\n * git/IO error occurs. The session_id (resolved upstream) is carried so the\n * Enricher can attribute later out-of-band commits to the active session.\n */\nfunction emitWorktree(\n collector: EventCollector,\n hook: string,\n cwd: string,\n sessionId: string,\n source: string,\n git: GitRunner | undefined,\n extraAttributes?: Record<string, AttrValue>,\n): number {\n if (!WORKTREE_CAPTURE_HOOKS.has(hook.toLowerCase())) return 0;\n\n const snapshot = git ? captureWorktree(cwd, git) : captureWorktree(cwd);\n if (!snapshot) return 0;\n\n const attributes = worktreeAttributes(snapshot, sessionId || undefined);\n if (extraAttributes) Object.assign(attributes, extraAttributes);\n collector.emit({\n source,\n eventType: \"promotion.worktree\",\n lifecycle: \"instant\",\n attributes,\n });\n return 1;\n}\n\n/**\n * Emit a single generic instant event (current pre-#114 behaviour) when no\n * mapping rule matches — preserves fail-open recording (AC-3).\n */\nfunction emitGenericFallback(\n collector: EventCollector,\n hook: string,\n sessionId: string,\n source: string,\n extraAttributes?: Record<string, AttrValue>,\n): number {\n const eventType = (GIT_HOOK_NAMES.has(hook) ? \"git.\" : \"cursor.\") + hook;\n const attributes: Record<string, AttrValue> = { \"hook.name\": hook };\n if (sessionId) attributes[\"session_id\"] = sessionId;\n if (extraAttributes) Object.assign(attributes, extraAttributes);\n collector.emit({\n source,\n eventType,\n lifecycle: \"instant\",\n attributes,\n });\n return 1;\n}\n\n/** Emit human-interaction events for the relevant hooks (AC-5). */\nfunction emitHumanEvents(\n collector: EventCollector,\n hook: string,\n hookInput: Record<string, unknown>,\n sessionId: string,\n): number {\n let count = 0;\n\n if (hook === \"beforeSubmitPrompt\") {\n const prompt = asString(hookInput.prompt);\n if (prompt !== undefined) {\n const attachments = Array.isArray(hookInput.attachments)\n ? (hookInput.attachments as unknown[])\n : [];\n emitHumanInstruction(collector, { sessionId, prompt, attachments });\n count += 1;\n }\n } else if (hook === \"afterShellExecution\") {\n const command = asString(hookInput.command);\n if (command !== undefined) {\n const exitCode = asNumber(hookInput.exit_code) ?? asNumber(hookInput.exitCode) ?? 0;\n const durationMs = asNumber(hookInput.duration);\n if (emitQualityGateResult(collector, { sessionId, command, exitCode, durationMs })) {\n count += 1;\n }\n const output = asString(hookInput.output) ?? \"\";\n if (emitPromotionPr(collector, { sessionId, command, output })) {\n count += 1;\n }\n }\n }\n\n return count;\n}\n\n// ── Context file lifecycle ──────────────────────────────────────────────────\n\n/**\n * Manage the short-lived git context file (AC-4).\n *\n * beforeShellExecution + git commit/push/merge → atomic write\n * afterShellExecution + git commit/push/merge → delete (fail-open)\n *\n * Atomic write uses write-to-temp + rename so a git hook never observes a\n * partially written file. Non-git commands never write the file.\n */\nasync function manageContextFile(\n hook: string,\n hookInput: Record<string, unknown>,\n sessionId: string,\n dbPath: string,\n): Promise<void> {\n const command = asString(hookInput.command) ?? \"\";\n const isGitContextCommand = GIT_CONTEXT_COMMAND_RE.test(command);\n const file = contextFilePath(dbPath);\n\n if (hook === \"beforeShellExecution\" && isGitContextCommand) {\n await mkdir(dirname(file), { recursive: true });\n const payload =\n JSON.stringify({\n session_id: sessionId || null,\n nonce: generateId(),\n created_at_ms: Date.now(),\n }) + \"\\n\";\n const tmp = `${file}.${process.pid}.${generateId()}.tmp`;\n await writeFile(tmp, payload, \"utf8\");\n await rename(tmp, file);\n } else if (hook === \"afterShellExecution\" && isGitContextCommand) {\n await unlink(file).catch(() => {\n // rm -f semantics: missing file is not an error.\n });\n }\n}\n\n// ── Core (testable) ────────────────────────────────────────────────────────────\n\n/**\n * Run the hook recorder against an already-parsed hook input.\n *\n * Separated from {@link handleRecordHook} (which reads stdin) so it can be unit\n * tested without mocking process.stdin.\n */\nexport async function runRecordHook(args: {\n hook: string;\n hookInput: Record<string, unknown>;\n dbPath: string;\n mappingConfigPath: string;\n /** Event source label (default \"cursor-hook\"; \"claude-hook\" for the Claude binding). */\n source?: string;\n /** Pre-resolved session_id; defaults to the hook payload's conversation_id. */\n sessionId?: string;\n /** Extra attributes injected on every emitted span (e.g. repo_id for the ledger). */\n extraAttributes?: Record<string, AttrValue>;\n /**\n * Working directory used for promotion.worktree capture (#175). Defaults to the\n * hook payload's `cwd`, then process.cwd().\n */\n cwd?: string;\n /** Injectable git runner for worktree capture (testability). Defaults to real git. */\n git?: GitRunner;\n}): Promise<RecordHookResult> {\n const { hook, hookInput, dbPath, mappingConfigPath } = args;\n const source = args.source ?? \"cursor-hook\";\n const sessionId = args.sessionId ?? asString(hookInput.conversation_id) ?? \"\";\n const cwd = args.cwd ?? asString(hookInput.cwd) ?? process.cwd();\n\n // Context-file lifecycle is independent of recording and must not block it.\n await manageContextFile(hook, hookInput, sessionId, dbPath).catch(() => {\n // fail-open\n });\n\n // Load mapping config; absence/parse-error → generic fallback (AC-3).\n let config: EventMappingConfig | undefined;\n try {\n config = await loadEventMappingConfig(mappingConfigPath);\n } catch {\n config = undefined;\n }\n const rule = config?.mappings?.[hook];\n\n let recorded = 0;\n let fallback = false;\n\n // Enable the default enrichment rules (R1–R5) so hook-derived spans/links are\n // enriched on the same SQLite-backed cache the runtime populates, allowing\n // cross-axis (R5) linking across the process boundary (#143 / #140). The\n // Enricher is fail-open, so a rule error never blocks the underlying hook.\n const { collector, sink } = createPipeline({ dbPath });\n try {\n if (rule) {\n recorded += emitMappedSpans(\n collector,\n hook,\n rule,\n hookInput,\n sessionId,\n source,\n args.extraAttributes,\n );\n } else {\n recorded += emitGenericFallback(\n collector,\n hook,\n sessionId,\n source,\n args.extraAttributes,\n );\n fallback = true;\n }\n recorded += emitHumanEvents(collector, hook, hookInput, sessionId);\n // Working-tree HEAD capture (#175). Best-effort + fail-open: a git/IO error\n // must never block the underlying hook or the rest of the recording.\n try {\n recorded += emitWorktree(\n collector,\n hook,\n cwd,\n sessionId,\n source,\n args.git,\n args.extraAttributes,\n );\n } catch {\n // fail-open\n }\n } finally {\n sink.close();\n }\n\n return { recorded, fallback };\n}\n\n// ── Handler ─────────────────────────────────────────────────────────────────\n\n/**\n * Handle `aaac-observ record-hook <hook-name>`.\n *\n * Reads the hook JSON from stdin, records the resolved events, and prints\n * `{ recorded, fallback }` to stdout. Strictly fail-open: never throws and\n * always returns; the calling shell wrapper additionally guarantees exit 0.\n */\nexport async function handleRecordHook(\n hookName: string | undefined,\n options: RecordHookOptions,\n _parentOpts: Record<string, unknown>,\n): Promise<void> {\n const hook = hookName ?? \"unknown\";\n const dbPath = options.db ?? DEFAULT_DB_PATH;\n const mappingConfigPath = options.mappingConfig ?? defaultMappingConfigPath(dbPath);\n\n let result: RecordHookResult = { recorded: 0, fallback: false };\n try {\n const hookInput = parseHookInput(await readStdin());\n result = await runRecordHook({ hook, hookInput, dbPath, mappingConfigPath });\n } catch (err) {\n // Recorder is fail-open: report to stderr but never block the operation.\n process.stderr.write(\n `record-hook: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n }\n\n process.stdout.write(JSON.stringify(result) + \"\\n\");\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** User-global ledger path, relative to HOME (`~/.aaac/ledger.db`). */
|
|
3
|
+
declare function ledgerDbPath(home: string): string;
|
|
4
|
+
interface GlobalRecordResult {
|
|
5
|
+
/** Where the event was recorded. */
|
|
6
|
+
scope: "project" | "ledger";
|
|
7
|
+
/** Absolute db path written to. */
|
|
8
|
+
dbPath: string;
|
|
9
|
+
/** Resolved project root, when scope === "project". */
|
|
10
|
+
projectRoot?: string;
|
|
11
|
+
/** repo_id attribute, when scope === "ledger". */
|
|
12
|
+
repoId?: string;
|
|
13
|
+
/** Resolved session id (may be "unknown" provisional). */
|
|
14
|
+
sessionId: string;
|
|
15
|
+
/** Events recorded into the pipeline. */
|
|
16
|
+
recorded: number;
|
|
17
|
+
/** True when the generic fallback was used (no mapping rule matched). */
|
|
18
|
+
fallback: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compute a stable repo_id for the user-global ledger.
|
|
22
|
+
* Prefers a SHA-256 of the canonical git remote URL; falls back to a hash of the
|
|
23
|
+
* resolved cwd so records from a repo with no remote are still grouped.
|
|
24
|
+
*/
|
|
25
|
+
declare function computeRepoId(remoteUrl: string | undefined, fallbackPath: string): string;
|
|
26
|
+
/** Best-effort canonical git remote URL for a directory; undefined on any error. */
|
|
27
|
+
declare function gitRemoteUrl(cwd: string): string | undefined;
|
|
28
|
+
interface RunGlobalRecordArgs {
|
|
29
|
+
hook: string;
|
|
30
|
+
hookInput: Record<string, unknown>;
|
|
31
|
+
source?: string;
|
|
32
|
+
/** Overrides for testability (default to the real environment). */
|
|
33
|
+
env?: Record<string, string | undefined>;
|
|
34
|
+
home?: string;
|
|
35
|
+
processCwd?: string;
|
|
36
|
+
/** Injectable remote-URL resolver (defaults to {@link gitRemoteUrl}). */
|
|
37
|
+
resolveRemote?: (cwd: string) => string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Core (testable) global-record logic. Never throws — the caller guarantees
|
|
41
|
+
* exit 0 regardless of the outcome.
|
|
42
|
+
*/
|
|
43
|
+
declare function runGlobalRecord(args: RunGlobalRecordArgs): Promise<GlobalRecordResult>;
|
|
44
|
+
interface ParsedArgs {
|
|
45
|
+
hook: string;
|
|
46
|
+
source?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Parse `<hook-name> [--source <s>]` from argv (fail-open: unknown flags ignored). */
|
|
49
|
+
declare function parseGlobalRecordArgs(argv: string[]): ParsedArgs;
|
|
50
|
+
/** Run the wrapper end-to-end; always resolves (caller exits 0). */
|
|
51
|
+
declare function main(argv?: string[]): Promise<void>;
|
|
52
|
+
|
|
53
|
+
export { type GlobalRecordResult, type RunGlobalRecordArgs, computeRepoId, gitRemoteUrl, ledgerDbPath, main, parseGlobalRecordArgs, runGlobalRecord };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
contextFilePath,
|
|
4
|
+
defaultMappingConfigPath,
|
|
5
|
+
parseHookInput,
|
|
6
|
+
readStdin,
|
|
7
|
+
runRecordHook
|
|
8
|
+
} from "../chunk-EKFRH7PX.js";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_DB_PATH
|
|
11
|
+
} from "../chunk-3DXZNA3E.js";
|
|
12
|
+
|
|
13
|
+
// src/cli/global-record.ts
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
import { execFileSync } from "child_process";
|
|
16
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { join as join2 } from "path";
|
|
19
|
+
|
|
20
|
+
// src/cli/session-id.ts
|
|
21
|
+
var UNKNOWN_SESSION_ID = "unknown";
|
|
22
|
+
function nonEmptyString(value) {
|
|
23
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
24
|
+
}
|
|
25
|
+
function resolveSessionId(input = {}) {
|
|
26
|
+
const env = input.env ?? process.env;
|
|
27
|
+
const hookInput = input.hookInput ?? {};
|
|
28
|
+
const contextJson = input.contextJson ?? {};
|
|
29
|
+
const aaac = nonEmptyString(env.AAAC_SESSION_ID);
|
|
30
|
+
if (aaac) {
|
|
31
|
+
return { sessionId: aaac, source: "env-aaac", provisional: false };
|
|
32
|
+
}
|
|
33
|
+
const conversationId = nonEmptyString(hookInput.conversation_id);
|
|
34
|
+
if (conversationId) {
|
|
35
|
+
return { sessionId: conversationId, source: "conversation_id", provisional: false };
|
|
36
|
+
}
|
|
37
|
+
const fromContext = nonEmptyString(contextJson.session_id);
|
|
38
|
+
if (fromContext) {
|
|
39
|
+
return { sessionId: fromContext, source: "observ-context", provisional: false };
|
|
40
|
+
}
|
|
41
|
+
const claude = nonEmptyString(env.CLAUDE_SESSION_ID);
|
|
42
|
+
if (claude) {
|
|
43
|
+
return { sessionId: claude, source: "env-claude", provisional: false };
|
|
44
|
+
}
|
|
45
|
+
return { sessionId: UNKNOWN_SESSION_ID, source: "unknown", provisional: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/cli/walk-up.ts
|
|
49
|
+
import { existsSync, statSync } from "fs";
|
|
50
|
+
import { dirname, join, parse as parsePath, resolve } from "path";
|
|
51
|
+
var PROJECT_MANIFEST = "project.yaml";
|
|
52
|
+
function isNonEmptyString(value) {
|
|
53
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
54
|
+
}
|
|
55
|
+
function collectCandidatePaths(input) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const push = (value) => {
|
|
58
|
+
if (isNonEmptyString(value)) out.push(value);
|
|
59
|
+
};
|
|
60
|
+
push(input.cwd);
|
|
61
|
+
if (Array.isArray(input.workspaceRoots)) {
|
|
62
|
+
for (const root of input.workspaceRoots) push(root);
|
|
63
|
+
}
|
|
64
|
+
push(input.filePath);
|
|
65
|
+
push(input.processCwd ?? process.cwd());
|
|
66
|
+
const seen = /* @__PURE__ */ new Set();
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const p of out) {
|
|
69
|
+
const abs = resolve(p);
|
|
70
|
+
if (!seen.has(abs)) {
|
|
71
|
+
seen.add(abs);
|
|
72
|
+
result.push(abs);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function toStartDir(candidate) {
|
|
78
|
+
const abs = resolve(candidate);
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
81
|
+
return abs;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
return dirname(abs);
|
|
86
|
+
}
|
|
87
|
+
function walkUpForProject(startDir) {
|
|
88
|
+
let dir = toStartDir(startDir);
|
|
89
|
+
const { root } = parsePath(dir);
|
|
90
|
+
while (true) {
|
|
91
|
+
try {
|
|
92
|
+
if (existsSync(join(dir, PROJECT_MANIFEST))) {
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
if (dir === root) break;
|
|
98
|
+
const parent = dirname(dir);
|
|
99
|
+
if (parent === dir) break;
|
|
100
|
+
dir = parent;
|
|
101
|
+
}
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
function resolveProjectRoot(candidates) {
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
const found = walkUpForProject(candidate);
|
|
107
|
+
if (found) return found;
|
|
108
|
+
}
|
|
109
|
+
return void 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/cli/global-record.ts
|
|
113
|
+
function ledgerDbPath(home) {
|
|
114
|
+
return join2(home, ".aaac", "ledger.db");
|
|
115
|
+
}
|
|
116
|
+
function computeRepoId(remoteUrl, fallbackPath) {
|
|
117
|
+
const basis = remoteUrl && remoteUrl.trim().length > 0 ? `remote:${remoteUrl.trim()}` : `path:${fallbackPath}`;
|
|
118
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
119
|
+
}
|
|
120
|
+
function readContextJson(dbPath) {
|
|
121
|
+
try {
|
|
122
|
+
const file = contextFilePath(dbPath);
|
|
123
|
+
if (!existsSync2(file)) return {};
|
|
124
|
+
const raw = readFileSync(file, "utf8");
|
|
125
|
+
return parseHookInput(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function gitRemoteUrl(cwd) {
|
|
131
|
+
try {
|
|
132
|
+
const out = execFileSync("git", ["-C", cwd, "config", "--get", "remote.origin.url"], {
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
135
|
+
timeout: 2e3
|
|
136
|
+
});
|
|
137
|
+
const trimmed = out.trim();
|
|
138
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
139
|
+
} catch {
|
|
140
|
+
return void 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function runGlobalRecord(args) {
|
|
144
|
+
const { hook, hookInput } = args;
|
|
145
|
+
const env = args.env ?? process.env;
|
|
146
|
+
const home = args.home ?? homedir();
|
|
147
|
+
const processCwd = args.processCwd ?? process.cwd();
|
|
148
|
+
const source = args.source ?? "global-hook";
|
|
149
|
+
const resolveRemote = args.resolveRemote ?? gitRemoteUrl;
|
|
150
|
+
const candidates = collectCandidatePaths({
|
|
151
|
+
cwd: hookInput.cwd,
|
|
152
|
+
workspaceRoots: hookInput.workspace_roots,
|
|
153
|
+
filePath: hookInput.file_path,
|
|
154
|
+
processCwd
|
|
155
|
+
});
|
|
156
|
+
const projectRoot = resolveProjectRoot(candidates);
|
|
157
|
+
if (projectRoot) {
|
|
158
|
+
const dbPath2 = join2(projectRoot, DEFAULT_DB_PATH);
|
|
159
|
+
const contextJson2 = readContextJson(dbPath2);
|
|
160
|
+
const { sessionId: sessionId2 } = resolveSessionId({ hookInput, env, contextJson: contextJson2 });
|
|
161
|
+
const { recorded: recorded2, fallback: fallback2 } = await runRecordHook({
|
|
162
|
+
hook,
|
|
163
|
+
hookInput,
|
|
164
|
+
dbPath: dbPath2,
|
|
165
|
+
mappingConfigPath: defaultMappingConfigPath(dbPath2),
|
|
166
|
+
source,
|
|
167
|
+
sessionId: sessionId2
|
|
168
|
+
});
|
|
169
|
+
return { scope: "project", dbPath: dbPath2, projectRoot, sessionId: sessionId2, recorded: recorded2, fallback: fallback2 };
|
|
170
|
+
}
|
|
171
|
+
const dbPath = ledgerDbPath(home);
|
|
172
|
+
const contextJson = readContextJson(dbPath);
|
|
173
|
+
const { sessionId } = resolveSessionId({ hookInput, env, contextJson });
|
|
174
|
+
let remote;
|
|
175
|
+
try {
|
|
176
|
+
remote = resolveRemote(processCwd);
|
|
177
|
+
} catch {
|
|
178
|
+
remote = void 0;
|
|
179
|
+
}
|
|
180
|
+
const repoId = computeRepoId(remote, processCwd);
|
|
181
|
+
const extraAttributes = { repo_id: repoId };
|
|
182
|
+
const { recorded, fallback } = await runRecordHook({
|
|
183
|
+
hook,
|
|
184
|
+
hookInput,
|
|
185
|
+
dbPath,
|
|
186
|
+
mappingConfigPath: defaultMappingConfigPath(dbPath),
|
|
187
|
+
source,
|
|
188
|
+
sessionId,
|
|
189
|
+
extraAttributes
|
|
190
|
+
});
|
|
191
|
+
return { scope: "ledger", dbPath, repoId, sessionId, recorded, fallback };
|
|
192
|
+
}
|
|
193
|
+
function parseGlobalRecordArgs(argv) {
|
|
194
|
+
let hook = "unknown";
|
|
195
|
+
let source;
|
|
196
|
+
let sawHook = false;
|
|
197
|
+
for (let i = 0; i < argv.length; i++) {
|
|
198
|
+
const arg = argv[i];
|
|
199
|
+
if (arg === "--source") {
|
|
200
|
+
source = argv[++i];
|
|
201
|
+
} else if (arg.startsWith("--source=")) {
|
|
202
|
+
source = arg.slice("--source=".length);
|
|
203
|
+
} else if (!arg.startsWith("-") && !sawHook) {
|
|
204
|
+
hook = arg;
|
|
205
|
+
sawHook = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { hook, source };
|
|
209
|
+
}
|
|
210
|
+
async function main(argv = process.argv.slice(2)) {
|
|
211
|
+
let result;
|
|
212
|
+
try {
|
|
213
|
+
const { hook, source } = parseGlobalRecordArgs(argv);
|
|
214
|
+
const hookInput = parseHookInput(await readStdin());
|
|
215
|
+
result = await runGlobalRecord({ hook, hookInput, source });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
process.stderr.write(
|
|
218
|
+
`global-record: ${err instanceof Error ? err.message : String(err)}
|
|
219
|
+
`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (result) {
|
|
223
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (process.argv[1] && process.argv[1].endsWith("global-record.js")) {
|
|
227
|
+
void main().finally(() => {
|
|
228
|
+
process.exit(0);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
computeRepoId,
|
|
233
|
+
gitRemoteUrl,
|
|
234
|
+
ledgerDbPath,
|
|
235
|
+
main,
|
|
236
|
+
parseGlobalRecordArgs,
|
|
237
|
+
runGlobalRecord
|
|
238
|
+
};
|
|
239
|
+
//# sourceMappingURL=global-record.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/cli/global-record.ts","../../src/cli/session-id.ts","../../src/cli/walk-up.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * aaac-observ-global-record — user-global hook recorder (Issue #174 / T-A2).\n *\n * This is the binary wired into the USER-scope Cursor / Claude / Git hooks\n * installed by `aaac observability install --global`. Unlike `record-hook`\n * (which assumes it runs inside a configured repo with a known db path), this\n * wrapper is location-agnostic:\n *\n * 1. parse the hook JSON payload from stdin\n * 2. collect candidate paths (cwd / workspace_roots / file_path + process.cwd)\n * and WALK UP to the nearest enclosing project.yaml\n * 3. resolve session_id through the multi-stage chain (AAAC_* → conversation_id\n * → .observ-context.json → CLAUDE_* → unknown)\n * 4a. RESOLVED → append to <project>/.agent-logs/observability.db\n * 4b. UNRESOLVED → append to ~/.aaac/ledger.db tagged with repo_id (a stable\n * hash of the git remote URL, or the cwd when there is no remote)\n *\n * Strictly fail-open: every walk/IO/exception path still exits 0 so the wrapper\n * can NEVER block the underlying Cursor/Claude/Git operation.\n *\n * Proposal: docs/proposal/observability-integration.md §ローカルグローバル記録.\n */\nimport { createHash } from \"node:crypto\";\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { DEFAULT_DB_PATH } from \"../index.js\";\nimport type { AttrValue } from \"../types/canonical-event.js\";\nimport {\n contextFilePath,\n defaultMappingConfigPath,\n parseHookInput,\n readStdin,\n runRecordHook,\n} from \"./record-hook.js\";\nimport { resolveSessionId } from \"./session-id.js\";\nimport { collectCandidatePaths, resolveProjectRoot } from \"./walk-up.js\";\n\n/** User-global ledger path, relative to HOME (`~/.aaac/ledger.db`). */\nexport function ledgerDbPath(home: string): string {\n return join(home, \".aaac\", \"ledger.db\");\n}\n\nexport interface GlobalRecordResult {\n /** Where the event was recorded. */\n scope: \"project\" | \"ledger\";\n /** Absolute db path written to. */\n dbPath: string;\n /** Resolved project root, when scope === \"project\". */\n projectRoot?: string;\n /** repo_id attribute, when scope === \"ledger\". */\n repoId?: string;\n /** Resolved session id (may be \"unknown\" provisional). */\n sessionId: string;\n /** Events recorded into the pipeline. */\n recorded: number;\n /** True when the generic fallback was used (no mapping rule matched). */\n fallback: boolean;\n}\n\n/**\n * Compute a stable repo_id for the user-global ledger.\n * Prefers a SHA-256 of the canonical git remote URL; falls back to a hash of the\n * resolved cwd so records from a repo with no remote are still grouped.\n */\nexport function computeRepoId(remoteUrl: string | undefined, fallbackPath: string): string {\n const basis = remoteUrl && remoteUrl.trim().length > 0 ? `remote:${remoteUrl.trim()}` : `path:${fallbackPath}`;\n return createHash(\"sha256\").update(basis).digest(\"hex\").slice(0, 16);\n}\n\n/** Read & parse `.observ-context.json` at the given db dir; fail-open to {}. */\nfunction readContextJson(dbPath: string): Record<string, unknown> {\n try {\n const file = contextFilePath(dbPath);\n if (!existsSync(file)) return {};\n const raw = readFileSync(file, \"utf8\");\n return parseHookInput(raw);\n } catch {\n return {};\n }\n}\n\n/** Best-effort canonical git remote URL for a directory; undefined on any error. */\nexport function gitRemoteUrl(cwd: string): string | undefined {\n try {\n const out = execFileSync(\"git\", [\"-C\", cwd, \"config\", \"--get\", \"remote.origin.url\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n timeout: 2000,\n });\n const trimmed = out.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n } catch {\n return undefined;\n }\n}\n\nexport interface RunGlobalRecordArgs {\n hook: string;\n hookInput: Record<string, unknown>;\n source?: string;\n /** Overrides for testability (default to the real environment). */\n env?: Record<string, string | undefined>;\n home?: string;\n processCwd?: string;\n /** Injectable remote-URL resolver (defaults to {@link gitRemoteUrl}). */\n resolveRemote?: (cwd: string) => string | undefined;\n}\n\n/**\n * Core (testable) global-record logic. Never throws — the caller guarantees\n * exit 0 regardless of the outcome.\n */\nexport async function runGlobalRecord(args: RunGlobalRecordArgs): Promise<GlobalRecordResult> {\n const { hook, hookInput } = args;\n const env = args.env ?? process.env;\n const home = args.home ?? homedir();\n const processCwd = args.processCwd ?? process.cwd();\n const source = args.source ?? \"global-hook\";\n const resolveRemote = args.resolveRemote ?? gitRemoteUrl;\n\n const candidates = collectCandidatePaths({\n cwd: hookInput.cwd,\n workspaceRoots: hookInput.workspace_roots,\n filePath: hookInput.file_path,\n processCwd,\n });\n const projectRoot = resolveProjectRoot(candidates);\n\n if (projectRoot) {\n const dbPath = join(projectRoot, DEFAULT_DB_PATH);\n const contextJson = readContextJson(dbPath);\n const { sessionId } = resolveSessionId({ hookInput, env, contextJson });\n const { recorded, fallback } = await runRecordHook({\n hook,\n hookInput,\n dbPath,\n mappingConfigPath: defaultMappingConfigPath(dbPath),\n source,\n sessionId,\n });\n return { scope: \"project\", dbPath, projectRoot, sessionId, recorded, fallback };\n }\n\n // Unresolved → user-global ledger, tagged with repo_id.\n const dbPath = ledgerDbPath(home);\n const contextJson = readContextJson(dbPath);\n const { sessionId } = resolveSessionId({ hookInput, env, contextJson });\n let remote: string | undefined;\n try {\n remote = resolveRemote(processCwd);\n } catch {\n remote = undefined; // fail-open: no remote → cwd-hash repo_id\n }\n const repoId = computeRepoId(remote, processCwd);\n const extraAttributes: Record<string, AttrValue> = { repo_id: repoId };\n const { recorded, fallback } = await runRecordHook({\n hook,\n hookInput,\n dbPath,\n mappingConfigPath: defaultMappingConfigPath(dbPath),\n source,\n sessionId,\n extraAttributes,\n });\n return { scope: \"ledger\", dbPath, repoId, sessionId, recorded, fallback };\n}\n\n// ── CLI argument parsing ─────────────────────────────────────────────────────\n\ninterface ParsedArgs {\n hook: string;\n source?: string;\n}\n\n/** Parse `<hook-name> [--source <s>]` from argv (fail-open: unknown flags ignored). */\nexport function parseGlobalRecordArgs(argv: string[]): ParsedArgs {\n let hook = \"unknown\";\n let source: string | undefined;\n let sawHook = false;\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i];\n if (arg === \"--source\") {\n source = argv[++i];\n } else if (arg.startsWith(\"--source=\")) {\n source = arg.slice(\"--source=\".length);\n } else if (!arg.startsWith(\"-\") && !sawHook) {\n hook = arg;\n sawHook = true;\n }\n }\n return { hook, source };\n}\n\n// ── Entrypoint ───────────────────────────────────────────────────────────────\n\n/** Run the wrapper end-to-end; always resolves (caller exits 0). */\nexport async function main(argv: string[] = process.argv.slice(2)): Promise<void> {\n let result: GlobalRecordResult | undefined;\n try {\n const { hook, source } = parseGlobalRecordArgs(argv);\n const hookInput = parseHookInput(await readStdin());\n result = await runGlobalRecord({ hook, hookInput, source });\n } catch (err) {\n process.stderr.write(\n `global-record: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n }\n if (result) {\n process.stdout.write(JSON.stringify(result) + \"\\n\");\n }\n}\n\n// Only auto-run when invoked directly as a binary (not when imported by tests).\nif (process.argv[1] && process.argv[1].endsWith(\"global-record.js\")) {\n void main().finally(() => {\n // fail-open: ALWAYS exit 0.\n process.exit(0);\n });\n}\n","/**\n * session_id resolution — multi-stage, fail-open (Issue #174 / T-A6).\n *\n * The user-global hook path runs OUTSIDE the @aaac/runtime process and often\n * outside a configured repo, so the in-process correlation key (conversation_id\n * injected by record-hook) may be absent. To still correlate hook-derived spans\n * we resolve session_id through a fixed priority chain and, when every source\n * fails, fall back to a provisional `unknown` marker rather than dropping the\n * record (records must NEVER be lost — fail-open).\n *\n * Priority (highest first):\n * 1. AAAC_* explicit env override (AAAC_SESSION_ID)\n * 2. conversation_id hook payload field (Cursor / runtime correlation key)\n * 3. .observ-context.json short-lived git-context file written by record-hook\n * 4. CLAUDE_* Claude Code env (CLAUDE_SESSION_ID)\n * 5. unknown provisional marker (provisional = true)\n *\n * Pure and synchronous: the caller is responsible for reading the (optional)\n * .observ-context.json contents and passing the parsed object. This keeps the\n * resolver trivially unit-testable per path (one test per priority level).\n *\n * Proposal: docs/proposal/observability-integration.md §session_id 解決.\n */\n\n/** Provisional marker used when no concrete session_id can be resolved. */\nexport const UNKNOWN_SESSION_ID = \"unknown\";\n\n/** Which priority rung produced the resolved session_id. */\nexport type SessionIdSource =\n | \"env-aaac\"\n | \"conversation_id\"\n | \"observ-context\"\n | \"env-claude\"\n | \"unknown\";\n\nexport interface ResolveSessionIdInput {\n /** Parsed hook payload from stdin (conversation_id, etc.). */\n hookInput?: Record<string, unknown>;\n /** Process environment (defaults to process.env). */\n env?: Record<string, string | undefined>;\n /** Parsed contents of `.observ-context.json` (read fail-open by the caller). */\n contextJson?: Record<string, unknown>;\n}\n\nexport interface ResolvedSessionId {\n /** Resolved session id, or {@link UNKNOWN_SESSION_ID} when unresolvable. */\n sessionId: string;\n /** The priority rung that produced the value. */\n source: SessionIdSource;\n /** True when no concrete source matched (sessionId === UNKNOWN_SESSION_ID). */\n provisional: boolean;\n}\n\n/** Return value when it is a non-empty string, else undefined. */\nfunction nonEmptyString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0 ? value : undefined;\n}\n\n/**\n * Resolve a session_id through the priority chain (1–5). Never throws.\n *\n * @returns the first matching source, or a provisional `unknown` marker.\n */\nexport function resolveSessionId(input: ResolveSessionIdInput = {}): ResolvedSessionId {\n const env = input.env ?? process.env;\n const hookInput = input.hookInput ?? {};\n const contextJson = input.contextJson ?? {};\n\n // 1. AAAC_* explicit override\n const aaac = nonEmptyString(env.AAAC_SESSION_ID);\n if (aaac) {\n return { sessionId: aaac, source: \"env-aaac\", provisional: false };\n }\n\n // 2. conversation_id from the hook payload\n const conversationId = nonEmptyString(hookInput.conversation_id);\n if (conversationId) {\n return { sessionId: conversationId, source: \"conversation_id\", provisional: false };\n }\n\n // 3. .observ-context.json (short-lived git-context file)\n const fromContext = nonEmptyString(contextJson.session_id);\n if (fromContext) {\n return { sessionId: fromContext, source: \"observ-context\", provisional: false };\n }\n\n // 4. CLAUDE_* env\n const claude = nonEmptyString(env.CLAUDE_SESSION_ID);\n if (claude) {\n return { sessionId: claude, source: \"env-claude\", provisional: false };\n }\n\n // 5. provisional unknown\n return { sessionId: UNKNOWN_SESSION_ID, source: \"unknown\", provisional: true };\n}\n","/**\n * project.yaml walk-up resolution (Issue #174 / T-A2).\n *\n * The user-global hook records from any CWD, including repos that never ran\n * `aaac init`. To attribute a hook event to a project we walk UP the directory\n * tree from a set of candidate start paths (the hook payload's cwd /\n * workspace_roots / file_path, plus process.cwd()) until we find a `project.yaml`.\n *\n * Resolution rules:\n * - The nearest enclosing project.yaml wins (innermost in a nested monorepo).\n * - Candidates are tried in priority order; the first that resolves wins.\n * - A file path is reduced to its containing directory before walking.\n * - The walk stops at the filesystem root.\n *\n * Pure and fail-open: any stat/IO error on a candidate is swallowed and the next\n * candidate is tried; an empty/!found result is a normal outcome (the caller\n * then records to the user-global ledger).\n *\n * Proposal: docs/proposal/observability-integration.md §ローカルグローバル記録.\n */\nimport { existsSync, statSync } from \"node:fs\";\nimport { dirname, join, parse as parsePath, resolve } from \"node:path\";\n\n/** The project manifest filename that anchors a project root. */\nexport const PROJECT_MANIFEST = \"project.yaml\";\n\nexport interface CollectCandidatesInput {\n /** Hook payload `cwd` field. */\n cwd?: unknown;\n /** Hook payload `workspace_roots` field (array of paths). */\n workspaceRoots?: unknown;\n /** Hook payload `file_path` field (the edited file). */\n filePath?: unknown;\n /** Process cwd fallback (defaults to process.cwd()). */\n processCwd?: string;\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\n/**\n * Collect ordered, de-duplicated candidate start directories from a hook payload.\n * Priority: cwd → workspace_roots (in order) → file_path → process.cwd().\n */\nexport function collectCandidatePaths(input: CollectCandidatesInput): string[] {\n const out: string[] = [];\n const push = (value: unknown): void => {\n if (isNonEmptyString(value)) out.push(value);\n };\n\n push(input.cwd);\n if (Array.isArray(input.workspaceRoots)) {\n for (const root of input.workspaceRoots) push(root);\n }\n push(input.filePath);\n push(input.processCwd ?? process.cwd());\n\n // De-duplicate by resolved absolute path while preserving order.\n const seen = new Set<string>();\n const result: string[] = [];\n for (const p of out) {\n const abs = resolve(p);\n if (!seen.has(abs)) {\n seen.add(abs);\n result.push(abs);\n }\n }\n return result;\n}\n\n/** Reduce a path to a directory: a file → its parent, a dir → itself. */\nfunction toStartDir(candidate: string): string {\n const abs = resolve(candidate);\n try {\n if (existsSync(abs) && statSync(abs).isDirectory()) {\n return abs;\n }\n } catch {\n // fall through — treat as a (possibly non-existent) file path\n }\n return dirname(abs);\n}\n\n/**\n * Walk up from a single start directory to the nearest enclosing project.yaml.\n * @returns the directory containing project.yaml, or undefined.\n */\nexport function walkUpForProject(startDir: string): string | undefined {\n let dir = toStartDir(startDir);\n const { root } = parsePath(dir);\n\n // Bounded by the filesystem root; the loop terminates because dirname()\n // is a fixed point at the root.\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n if (existsSync(join(dir, PROJECT_MANIFEST))) {\n return dir;\n }\n } catch {\n // ignore and keep climbing\n }\n if (dir === root) break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return undefined;\n}\n\n/**\n * Resolve a project root by walking up from each candidate path in priority\n * order. Returns the first project root found, or undefined when none of the\n * candidates is inside a project.\n */\nexport function resolveProjectRoot(candidates: string[]): string | undefined {\n for (const candidate of candidates) {\n const found = walkUpForProject(candidate);\n if (found) return found;\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;AAuBA,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B,SAAS,cAAAA,aAAY,oBAAoB;AACzC,SAAS,eAAe;AACxB,SAAS,QAAAC,aAAY;;;ACFd,IAAM,qBAAqB;AA6BlC,SAAS,eAAe,OAAoC;AAC1D,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,IAAI,QAAQ;AACxE;AAOO,SAAS,iBAAiB,QAA+B,CAAC,GAAsB;AACrF,QAAM,MAAM,MAAM,OAAO,QAAQ;AACjC,QAAM,YAAY,MAAM,aAAa,CAAC;AACtC,QAAM,cAAc,MAAM,eAAe,CAAC;AAG1C,QAAM,OAAO,eAAe,IAAI,eAAe;AAC/C,MAAI,MAAM;AACR,WAAO,EAAE,WAAW,MAAM,QAAQ,YAAY,aAAa,MAAM;AAAA,EACnE;AAGA,QAAM,iBAAiB,eAAe,UAAU,eAAe;AAC/D,MAAI,gBAAgB;AAClB,WAAO,EAAE,WAAW,gBAAgB,QAAQ,mBAAmB,aAAa,MAAM;AAAA,EACpF;AAGA,QAAM,cAAc,eAAe,YAAY,UAAU;AACzD,MAAI,aAAa;AACf,WAAO,EAAE,WAAW,aAAa,QAAQ,kBAAkB,aAAa,MAAM;AAAA,EAChF;AAGA,QAAM,SAAS,eAAe,IAAI,iBAAiB;AACnD,MAAI,QAAQ;AACV,WAAO,EAAE,WAAW,QAAQ,QAAQ,cAAc,aAAa,MAAM;AAAA,EACvE;AAGA,SAAO,EAAE,WAAW,oBAAoB,QAAQ,WAAW,aAAa,KAAK;AAC/E;;;AC1EA,SAAS,YAAY,gBAAgB;AACrC,SAAS,SAAS,MAAM,SAAS,WAAW,eAAe;AAGpD,IAAM,mBAAmB;AAahC,SAAS,iBAAiB,OAAiC;AACzD,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS;AAC5D;AAMO,SAAS,sBAAsB,OAAyC;AAC7E,QAAM,MAAgB,CAAC;AACvB,QAAM,OAAO,CAAC,UAAyB;AACrC,QAAI,iBAAiB,KAAK,EAAG,KAAI,KAAK,KAAK;AAAA,EAC7C;AAEA,OAAK,MAAM,GAAG;AACd,MAAI,MAAM,QAAQ,MAAM,cAAc,GAAG;AACvC,eAAW,QAAQ,MAAM,eAAgB,MAAK,IAAI;AAAA,EACpD;AACA,OAAK,MAAM,QAAQ;AACnB,OAAK,MAAM,cAAc,QAAQ,IAAI,CAAC;AAGtC,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,SAAmB,CAAC;AAC1B,aAAW,KAAK,KAAK;AACnB,UAAM,MAAM,QAAQ,CAAC;AACrB,QAAI,CAAC,KAAK,IAAI,GAAG,GAAG;AAClB,WAAK,IAAI,GAAG;AACZ,aAAO,KAAK,GAAG;AAAA,IACjB;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,WAAW,WAA2B;AAC7C,QAAM,MAAM,QAAQ,SAAS;AAC7B,MAAI;AACF,QAAI,WAAW,GAAG,KAAK,SAAS,GAAG,EAAE,YAAY,GAAG;AAClD,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO,QAAQ,GAAG;AACpB;AAMO,SAAS,iBAAiB,UAAsC;AACrE,MAAI,MAAM,WAAW,QAAQ;AAC7B,QAAM,EAAE,KAAK,IAAI,UAAU,GAAG;AAK9B,SAAO,MAAM;AACX,QAAI;AACF,UAAI,WAAW,KAAK,KAAK,gBAAgB,CAAC,GAAG;AAC3C,eAAO;AAAA,MACT;AAAA,IACF,QAAQ;AAAA,IAER;AACA,QAAI,QAAQ,KAAM;AAClB,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK;AACpB,UAAM;AAAA,EACR;AACA,SAAO;AACT;AAOO,SAAS,mBAAmB,YAA0C;AAC3E,aAAW,aAAa,YAAY;AAClC,UAAM,QAAQ,iBAAiB,SAAS;AACxC,QAAI,MAAO,QAAO;AAAA,EACpB;AACA,SAAO;AACT;;;AFjFO,SAAS,aAAa,MAAsB;AACjD,SAAOC,MAAK,MAAM,SAAS,WAAW;AACxC;AAwBO,SAAS,cAAc,WAA+B,cAA8B;AACzF,QAAM,QAAQ,aAAa,UAAU,KAAK,EAAE,SAAS,IAAI,UAAU,UAAU,KAAK,CAAC,KAAK,QAAQ,YAAY;AAC5G,SAAO,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACrE;AAGA,SAAS,gBAAgB,QAAyC;AAChE,MAAI;AACF,UAAM,OAAO,gBAAgB,MAAM;AACnC,QAAI,CAACC,YAAW,IAAI,EAAG,QAAO,CAAC;AAC/B,UAAM,MAAM,aAAa,MAAM,MAAM;AACrC,WAAO,eAAe,GAAG;AAAA,EAC3B,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAGO,SAAS,aAAa,KAAiC;AAC5D,MAAI;AACF,UAAM,MAAM,aAAa,OAAO,CAAC,MAAM,KAAK,UAAU,SAAS,mBAAmB,GAAG;AAAA,MACnF,UAAU;AAAA,MACV,OAAO,CAAC,UAAU,QAAQ,QAAQ;AAAA,MAClC,SAAS;AAAA,IACX,CAAC;AACD,UAAM,UAAU,IAAI,KAAK;AACzB,WAAO,QAAQ,SAAS,IAAI,UAAU;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAkBA,eAAsB,gBAAgB,MAAwD;AAC5F,QAAM,EAAE,MAAM,UAAU,IAAI;AAC5B,QAAM,MAAM,KAAK,OAAO,QAAQ;AAChC,QAAM,OAAO,KAAK,QAAQ,QAAQ;AAClC,QAAM,aAAa,KAAK,cAAc,QAAQ,IAAI;AAClD,QAAM,SAAS,KAAK,UAAU;AAC9B,QAAM,gBAAgB,KAAK,iBAAiB;AAE5C,QAAM,aAAa,sBAAsB;AAAA,IACvC,KAAK,UAAU;AAAA,IACf,gBAAgB,UAAU;AAAA,IAC1B,UAAU,UAAU;AAAA,IACpB;AAAA,EACF,CAAC;AACD,QAAM,cAAc,mBAAmB,UAAU;AAEjD,MAAI,aAAa;AACf,UAAMC,UAASF,MAAK,aAAa,eAAe;AAChD,UAAMG,eAAc,gBAAgBD,OAAM;AAC1C,UAAM,EAAE,WAAAE,WAAU,IAAI,iBAAiB,EAAE,WAAW,KAAK,aAAAD,aAAY,CAAC;AACtE,UAAM,EAAE,UAAAE,WAAU,UAAAC,UAAS,IAAI,MAAM,cAAc;AAAA,MACjD;AAAA,MACA;AAAA,MACA,QAAAJ;AAAA,MACA,mBAAmB,yBAAyBA,OAAM;AAAA,MAClD;AAAA,MACA,WAAAE;AAAA,IACF,CAAC;AACD,WAAO,EAAE,OAAO,WAAW,QAAAF,SAAQ,aAAa,WAAAE,YAAW,UAAAC,WAAU,UAAAC,UAAS;AAAA,EAChF;AAGA,QAAM,SAAS,aAAa,IAAI;AAChC,QAAM,cAAc,gBAAgB,MAAM;AAC1C,QAAM,EAAE,UAAU,IAAI,iBAAiB,EAAE,WAAW,KAAK,YAAY,CAAC;AACtE,MAAI;AACJ,MAAI;AACF,aAAS,cAAc,UAAU;AAAA,EACnC,QAAQ;AACN,aAAS;AAAA,EACX;AACA,QAAM,SAAS,cAAc,QAAQ,UAAU;AAC/C,QAAM,kBAA6C,EAAE,SAAS,OAAO;AACrE,QAAM,EAAE,UAAU,SAAS,IAAI,MAAM,cAAc;AAAA,IACjD;AAAA,IACA;AAAA,IACA;AAAA,IACA,mBAAmB,yBAAyB,MAAM;AAAA,IAClD;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AACD,SAAO,EAAE,OAAO,UAAU,QAAQ,QAAQ,WAAW,UAAU,SAAS;AAC1E;AAUO,SAAS,sBAAsB,MAA4B;AAChE,MAAI,OAAO;AACX,MAAI;AACJ,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,QAAQ,YAAY;AACtB,eAAS,KAAK,EAAE,CAAC;AAAA,IACnB,WAAW,IAAI,WAAW,WAAW,GAAG;AACtC,eAAS,IAAI,MAAM,YAAY,MAAM;AAAA,IACvC,WAAW,CAAC,IAAI,WAAW,GAAG,KAAK,CAAC,SAAS;AAC3C,aAAO;AACP,gBAAU;AAAA,IACZ;AAAA,EACF;AACA,SAAO,EAAE,MAAM,OAAO;AACxB;AAKA,eAAsB,KAAK,OAAiB,QAAQ,KAAK,MAAM,CAAC,GAAkB;AAChF,MAAI;AACJ,MAAI;AACF,UAAM,EAAE,MAAM,OAAO,IAAI,sBAAsB,IAAI;AACnD,UAAM,YAAY,eAAe,MAAM,UAAU,CAAC;AAClD,aAAS,MAAM,gBAAgB,EAAE,MAAM,WAAW,OAAO,CAAC;AAAA,EAC5D,SAAS,KAAK;AACZ,YAAQ,OAAO;AAAA,MACb,kBAAkB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA;AAAA,IACpE;AAAA,EACF;AACA,MAAI,QAAQ;AACV,YAAQ,OAAO,MAAM,KAAK,UAAU,MAAM,IAAI,IAAI;AAAA,EACpD;AACF;AAGA,IAAI,QAAQ,KAAK,CAAC,KAAK,QAAQ,KAAK,CAAC,EAAE,SAAS,kBAAkB,GAAG;AACnE,OAAK,KAAK,EAAE,QAAQ,MAAM;AAExB,YAAQ,KAAK,CAAC;AAAA,EAChB,CAAC;AACH;","names":["existsSync","join","join","existsSync","dbPath","contextJson","sessionId","recorded","fallback"]}
|