@pugi/cli 0.1.0-alpha.10

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,415 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { z } from 'zod';
7
+ import { recordHookInvoked, recordHookResult, recordHookSkipped, } from './session.js';
8
+ import { isTrustedWorkspace } from './trust.js';
9
+ export const ALL_HOOK_EVENTS = [
10
+ 'SessionStart',
11
+ 'UserPromptSubmit',
12
+ 'PreToolUse',
13
+ 'PermissionRequest',
14
+ 'PostToolUse',
15
+ 'PostToolUseFailure',
16
+ 'Stop',
17
+ 'SessionEnd',
18
+ ];
19
+ const hookEventSchema = z.enum([
20
+ 'SessionStart',
21
+ 'UserPromptSubmit',
22
+ 'PreToolUse',
23
+ 'PermissionRequest',
24
+ 'PostToolUse',
25
+ 'PostToolUseFailure',
26
+ 'Stop',
27
+ 'SessionEnd',
28
+ ]);
29
+ const hookMatchSchema = z
30
+ .object({
31
+ tool: z.string().min(1).optional(),
32
+ permission: z.enum(['read', 'edit', 'bash', 'network', 'mcp', 'subagent']).optional(),
33
+ pathGlob: z.string().min(1).optional(),
34
+ })
35
+ .strict();
36
+ const hookDefinitionSchema = z
37
+ .object({
38
+ event: hookEventSchema,
39
+ match: hookMatchSchema.optional(),
40
+ run: z.string().min(1),
41
+ timeoutMs: z.number().int().positive().max(60_000).optional(),
42
+ onFailure: z.enum(['warn', 'block']).optional(),
43
+ })
44
+ .strict();
45
+ const hooksFileSchema = z
46
+ .object({
47
+ hooks: z.array(hookDefinitionSchema).default([]),
48
+ })
49
+ .strict();
50
+ const DEFAULT_TIMEOUT_MS = 5_000;
51
+ const SIGKILL_GRACE_MS = 2_000;
52
+ // Cap each captured stream at 1 MiB so a misbehaving hook (`yes`, `cat
53
+ // /dev/urandom | base64`) cannot OOM the parent CLI by buffering
54
+ // unbounded output between data events and the SIGTERM watchdog. 1 MiB
55
+ // is generous headroom over realistic logger output while bounded.
56
+ const HOOK_STREAM_CAP_BYTES = 1024 * 1024;
57
+ export class HookRegistry {
58
+ options;
59
+ sources = [];
60
+ loaded = false;
61
+ /**
62
+ * Per-batch dedup memory. The caller is expected to call `resetBatch()`
63
+ * between independent event batches so hooks fire once per batch even
64
+ * if multiple identical events are emitted in tight succession from
65
+ * the same tool dispatch.
66
+ */
67
+ batchSeen = new Set();
68
+ trustedProjects = new Map();
69
+ constructor(options) {
70
+ this.options = options;
71
+ }
72
+ async load() {
73
+ this.sources.length = 0;
74
+ this.loaded = true;
75
+ const userPath = resolve(this.userHomeRoot(), 'hooks.json');
76
+ if (existsSync(userPath)) {
77
+ const userHooks = parseHooksFile(userPath);
78
+ this.sources.push({ origin: 'user', path: userPath, hooks: userHooks });
79
+ }
80
+ const projectPath = resolve(this.options.workspaceRoot, '.pugi/hooks.json');
81
+ if (existsSync(projectPath)) {
82
+ const trusted = await isTrustedWorkspace(this.options.workspaceRoot);
83
+ this.trustedProjects.set(this.options.workspaceRoot, trusted);
84
+ if (trusted) {
85
+ const projectHooks = parseHooksFile(projectPath);
86
+ this.sources.push({ origin: 'project', path: projectPath, hooks: projectHooks });
87
+ }
88
+ }
89
+ }
90
+ list(event) {
91
+ this.assertLoaded();
92
+ const all = this.sources.flatMap((source) => source.hooks);
93
+ return event ? all.filter((hook) => hook.event === event) : all;
94
+ }
95
+ /**
96
+ * Hooks that match the given event and context. The caller can use this
97
+ * to correlate hook definitions with `fire()` results positionally for
98
+ * downstream policy checks (e.g. PreToolUse blocking).
99
+ */
100
+ listMatching(ctx) {
101
+ return this.list(ctx.event).filter((hook) => matchesContext(hook, ctx));
102
+ }
103
+ /**
104
+ * Reset the per-batch dedup memory. Call between independent event
105
+ * batches (typically once per tool dispatch) so identical hook entries
106
+ * fire once per batch, not just once per session.
107
+ */
108
+ resetBatch() {
109
+ this.batchSeen.clear();
110
+ }
111
+ async fire(ctx) {
112
+ this.assertLoaded();
113
+ const candidates = this.listMatching(ctx);
114
+ // If the project hook file exists but the workspace is untrusted,
115
+ // emit a `hook.skipped: untrusted-project` once per `fire` so the
116
+ // audit log explains the gap. We only emit this if the user actually
117
+ // has a project hooks file on disk — otherwise there is nothing to
118
+ // skip.
119
+ const projectPath = resolve(this.options.workspaceRoot, '.pugi/hooks.json');
120
+ const projectHooksOnDisk = existsSync(projectPath);
121
+ const projectTrusted = this.trustedProjects.get(this.options.workspaceRoot) ?? false;
122
+ if (projectHooksOnDisk && !projectTrusted) {
123
+ this.recordSkipped(ctx, 'untrusted-project');
124
+ }
125
+ if (candidates.length === 0) {
126
+ return [];
127
+ }
128
+ const results = [];
129
+ for (const hook of candidates) {
130
+ const key = dedupKey(ctx.event, hook);
131
+ if (this.batchSeen.has(key)) {
132
+ this.recordSkipped(ctx, 'dedup');
133
+ results.push({
134
+ ok: true,
135
+ stdout: '',
136
+ stderr: '',
137
+ exitCode: 0,
138
+ elapsedMs: 0,
139
+ skipped: 'dedup',
140
+ });
141
+ continue;
142
+ }
143
+ this.batchSeen.add(key);
144
+ this.recordInvoked(ctx, hook);
145
+ const result = await executeHook(hook, ctx);
146
+ this.recordResult(ctx, result);
147
+ results.push(result);
148
+ }
149
+ return results;
150
+ }
151
+ assertLoaded() {
152
+ if (!this.loaded) {
153
+ throw new Error('HookRegistry.load() must be called before use');
154
+ }
155
+ }
156
+ userHomeRoot() {
157
+ const home = this.options.home ?? process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
158
+ return home;
159
+ }
160
+ recordInvoked(ctx, hook) {
161
+ if (!this.options.session)
162
+ return;
163
+ recordHookInvoked(this.options.session, {
164
+ event: ctx.event,
165
+ matchSummary: summariseMatch(hook.match),
166
+ runSummary: hook.run.slice(0, 200),
167
+ });
168
+ }
169
+ recordResult(ctx, result) {
170
+ if (!this.options.session)
171
+ return;
172
+ recordHookResult(this.options.session, {
173
+ event: ctx.event,
174
+ ok: result.ok,
175
+ exitCode: result.exitCode,
176
+ elapsedMs: result.elapsedMs,
177
+ stdoutLen: result.stdout.length,
178
+ stderrLen: result.stderr.length,
179
+ });
180
+ }
181
+ recordSkipped(ctx, reason) {
182
+ if (!this.options.session)
183
+ return;
184
+ recordHookSkipped(this.options.session, { event: ctx.event, reason });
185
+ }
186
+ }
187
+ /**
188
+ * Convenience helper for firing a SessionStart event from the CLI
189
+ * bootstrap path. Kept separate so the runtime entry point (cli.ts)
190
+ * does not need to construct a HookRegistry itself — that wiring lives
191
+ * in a follow-up PR by the TUI agent.
192
+ */
193
+ export async function fireSessionStart(registry, ctx) {
194
+ return registry.fire({ ...ctx, event: 'SessionStart' });
195
+ }
196
+ function parseHooksFile(path) {
197
+ let raw;
198
+ try {
199
+ raw = readFileSync(path, 'utf8');
200
+ }
201
+ catch (error) {
202
+ throw new Error(`hooks: cannot read ${path}: ${error.message}`);
203
+ }
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(raw);
207
+ }
208
+ catch (error) {
209
+ throw new Error(`hooks: ${path} is not valid JSON: ${error.message}`);
210
+ }
211
+ const result = hooksFileSchema.safeParse(parsed);
212
+ if (!result.success) {
213
+ const issues = result.error.issues
214
+ .map((issue) => `${issue.path.join('.')} ${issue.message}`)
215
+ .join('; ');
216
+ throw new Error(`hooks: ${path} failed schema validation: ${issues}`);
217
+ }
218
+ return result.data.hooks;
219
+ }
220
+ function matchesContext(hook, ctx) {
221
+ if (!hook.match)
222
+ return true;
223
+ const { tool, permission, pathGlob } = hook.match;
224
+ if (tool !== undefined) {
225
+ if (!ctx.tool)
226
+ return false;
227
+ if (!globMatch(tool, ctx.tool))
228
+ return false;
229
+ }
230
+ if (permission !== undefined) {
231
+ if (ctx.permission !== permission)
232
+ return false;
233
+ }
234
+ if (pathGlob !== undefined) {
235
+ if (!ctx.path)
236
+ return false;
237
+ if (!globMatch(pathGlob, ctx.path))
238
+ return false;
239
+ }
240
+ return true;
241
+ }
242
+ /**
243
+ * Tiny glob matcher: supports `*` (any chars except `/`) and `**` (any
244
+ * chars including `/`). Anchored at both ends. Plain strings without any
245
+ * wildcards must match exactly. We deliberately avoid pulling in a full
246
+ * glob lib here because the match grammar is intentionally narrow for M1.
247
+ */
248
+ function globMatch(pattern, value) {
249
+ // Escape regex special chars except * and /, then translate ** and *
250
+ // into their regex equivalents. Order matters: handle ** before *.
251
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
252
+ const translated = escaped.replace(/\*\*/g, '<<DOUBLESTAR>>').replace(/\*/g, '[^/]*').replace(/<<DOUBLESTAR>>/g, '.*');
253
+ const regex = new RegExp(`^${translated}$`);
254
+ return regex.test(value);
255
+ }
256
+ function stableJsonHash(value) {
257
+ const stringified = stableStringify(value);
258
+ return createHash('sha256').update(stringified).digest('hex').slice(0, 16);
259
+ }
260
+ function stableStringify(value) {
261
+ if (value === null || typeof value !== 'object') {
262
+ return JSON.stringify(value);
263
+ }
264
+ if (Array.isArray(value)) {
265
+ return `[${value.map((v) => stableStringify(v)).join(',')}]`;
266
+ }
267
+ const obj = value;
268
+ const keys = Object.keys(obj).sort();
269
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(',')}}`;
270
+ }
271
+ function dedupKey(event, hook) {
272
+ return `${event}:${stableJsonHash(hook.match ?? null)}:${stableJsonHash(hook.run)}`;
273
+ }
274
+ function summariseMatch(match) {
275
+ if (!match)
276
+ return '*';
277
+ const parts = [];
278
+ if (match.tool)
279
+ parts.push(`tool=${match.tool}`);
280
+ if (match.permission)
281
+ parts.push(`permission=${match.permission}`);
282
+ if (match.pathGlob)
283
+ parts.push(`path=${match.pathGlob}`);
284
+ return parts.length ? parts.join(',') : '*';
285
+ }
286
+ async function executeHook(hook, ctx) {
287
+ const timeoutMs = hook.timeoutMs ?? DEFAULT_TIMEOUT_MS;
288
+ const startedAt = Date.now();
289
+ return new Promise((resolvePromise) => {
290
+ const payloadJson = JSON.stringify(ctx.payload ?? null);
291
+ const child = spawn('/bin/sh', ['-c', hook.run], {
292
+ env: {
293
+ ...process.env,
294
+ PUGI_HOOK_PAYLOAD: payloadJson,
295
+ PUGI_HOOK_EVENT: ctx.event,
296
+ PUGI_HOOK_SESSION_ID: ctx.sessionId,
297
+ },
298
+ stdio: ['pipe', 'pipe', 'pipe'],
299
+ });
300
+ let stdout = '';
301
+ let stderr = '';
302
+ let killedForTimeout = false;
303
+ let killedForStreamCap = false;
304
+ let sigKillTimer;
305
+ const enforceStreamCap = () => {
306
+ if (killedForStreamCap)
307
+ return;
308
+ if (stdout.length + stderr.length <= HOOK_STREAM_CAP_BYTES)
309
+ return;
310
+ killedForStreamCap = true;
311
+ child.kill('SIGTERM');
312
+ // Reuse the same SIGKILL escalation as the timeout path.
313
+ if (!sigKillTimer) {
314
+ sigKillTimer = setTimeout(() => {
315
+ if (!child.killed)
316
+ child.kill('SIGKILL');
317
+ }, SIGKILL_GRACE_MS);
318
+ if (sigKillTimer.unref)
319
+ sigKillTimer.unref();
320
+ }
321
+ };
322
+ child.stdout?.on('data', (chunk) => {
323
+ if (killedForStreamCap)
324
+ return;
325
+ stdout += chunk.toString('utf8');
326
+ enforceStreamCap();
327
+ });
328
+ child.stderr?.on('data', (chunk) => {
329
+ if (killedForStreamCap)
330
+ return;
331
+ stderr += chunk.toString('utf8');
332
+ enforceStreamCap();
333
+ });
334
+ // Write the payload to stdin so hooks that prefer reading from stdin
335
+ // (e.g. `jq .`) work without depending on the env var. Hooks that
336
+ // do not consume stdin (e.g. `echo done`) close their end of the
337
+ // pipe immediately; writing then raises EPIPE which we swallow —
338
+ // payload-via-stdin is best-effort, not required.
339
+ if (child.stdin) {
340
+ child.stdin.on('error', () => {
341
+ // EPIPE / ECONNRESET when the child closed stdin before reading.
342
+ // Safe to ignore: PUGI_HOOK_PAYLOAD env var still carries the data.
343
+ });
344
+ child.stdin.end(payloadJson);
345
+ }
346
+ const timer = setTimeout(() => {
347
+ killedForTimeout = true;
348
+ child.kill('SIGTERM');
349
+ // Escalate to SIGKILL if the process refuses to exit.
350
+ sigKillTimer = setTimeout(() => {
351
+ if (!child.killed) {
352
+ child.kill('SIGKILL');
353
+ }
354
+ }, SIGKILL_GRACE_MS);
355
+ if (sigKillTimer.unref)
356
+ sigKillTimer.unref();
357
+ }, timeoutMs);
358
+ if (timer.unref)
359
+ timer.unref();
360
+ child.on('error', (error) => {
361
+ clearTimeout(timer);
362
+ if (sigKillTimer)
363
+ clearTimeout(sigKillTimer);
364
+ resolvePromise({
365
+ ok: false,
366
+ stdout,
367
+ stderr: stderr || `hook spawn error: ${error.message}`,
368
+ exitCode: -1,
369
+ elapsedMs: Date.now() - startedAt,
370
+ });
371
+ });
372
+ child.on('close', (code, signal) => {
373
+ clearTimeout(timer);
374
+ if (sigKillTimer)
375
+ clearTimeout(sigKillTimer);
376
+ const elapsedMs = Date.now() - startedAt;
377
+ // When killed by signal, child_process reports code=null, signal=<name>.
378
+ // Translate to a negative numeric exit code so callers can read it
379
+ // uniformly (-15 for SIGTERM, -9 for SIGKILL). This matches the
380
+ // convention several Node tools (cross-spawn, execa) use.
381
+ let exitCode;
382
+ if (code !== null) {
383
+ exitCode = code;
384
+ }
385
+ else if (signal === 'SIGTERM') {
386
+ exitCode = -15;
387
+ }
388
+ else if (signal === 'SIGKILL') {
389
+ exitCode = -9;
390
+ }
391
+ else {
392
+ exitCode = -1;
393
+ }
394
+ // `ok` is true iff the hook exited cleanly (exit 0, not killed).
395
+ // Both `onFailure: 'warn'` and `onFailure: 'block'` produce ok=false
396
+ // on a non-zero exit — the difference is how the CALLER reacts.
397
+ // The caller reads `hook.onFailure` (via list()) to decide whether
398
+ // a non-ok result should warn or block the originating action.
399
+ // A stream-cap kill is also a failure: the hook produced too much
400
+ // output and we cannot trust whatever partial result we captured.
401
+ const ok = exitCode === 0 && !killedForTimeout && !killedForStreamCap;
402
+ const stderrFinal = killedForStreamCap
403
+ ? `${stderr}\nhook output exceeded ${HOOK_STREAM_CAP_BYTES} bytes; killed by stream cap.`
404
+ : stderr;
405
+ resolvePromise({
406
+ ok,
407
+ stdout,
408
+ stderr: stderrFinal,
409
+ exitCode,
410
+ elapsedMs,
411
+ });
412
+ });
413
+ });
414
+ }
415
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,260 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { auditEventSchema } from '@pugi/sdk';
5
+ /**
6
+ * `.pugi/index.json` — materialized session + artifact index.
7
+ *
8
+ * The append-only event log at `.pugi/events.jsonl` is the source of truth.
9
+ * `index.json` is a cached projection rebuilt on demand. Reads prefer the
10
+ * cached view; writes (idea/plan/build/review/handoff/resume) update both.
11
+ *
12
+ * Local-first invariant: this file is regenerable from events.jsonl plus
13
+ * filesystem scan of artifacts/handoffs. Deleting it never loses data.
14
+ */
15
+ export const pugiIndexArtifactSchema = z.object({
16
+ id: z.string().min(1),
17
+ kind: z.enum([
18
+ 'idea',
19
+ 'plan',
20
+ 'build',
21
+ 'review',
22
+ 'triple-review',
23
+ 'resume',
24
+ 'handoff',
25
+ 'other',
26
+ ]),
27
+ path: z.string().min(1),
28
+ sessionId: z.string().min(1).nullable(),
29
+ createdAt: z.string().datetime(),
30
+ files: z.array(z.string()).default([]),
31
+ });
32
+ export const pugiIndexSessionSchema = z.object({
33
+ id: z.string().min(1),
34
+ startedAt: z.string().datetime(),
35
+ endedAt: z.string().datetime().nullable(),
36
+ commandCount: z.number().int().nonnegative(),
37
+ commands: z.array(z.object({
38
+ command: z.string().min(1),
39
+ status: z.enum(['started', 'success', 'error']),
40
+ timestamp: z.string().datetime(),
41
+ })),
42
+ artifactIds: z.array(z.string()).default([]),
43
+ });
44
+ export const pugiIndexSchema = z.object({
45
+ schema: z.literal(1),
46
+ updatedAt: z.string().datetime(),
47
+ artifacts: z.array(pugiIndexArtifactSchema),
48
+ sessions: z.array(pugiIndexSessionSchema),
49
+ });
50
+ export function indexPath(root) {
51
+ return resolve(root, '.pugi', 'index.json');
52
+ }
53
+ export function readIndex(root) {
54
+ const path = indexPath(root);
55
+ if (!existsSync(path))
56
+ return null;
57
+ try {
58
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
59
+ const parsed = pugiIndexSchema.safeParse(raw);
60
+ return parsed.success ? parsed.data : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ export function writeIndex(root, index) {
67
+ const path = indexPath(root);
68
+ writeFileSync(path, `${JSON.stringify(index, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
69
+ }
70
+ export function emptyIndex() {
71
+ return {
72
+ schema: 1,
73
+ updatedAt: new Date().toISOString(),
74
+ artifacts: [],
75
+ sessions: [],
76
+ };
77
+ }
78
+ /**
79
+ * Rebuild the index from the event log + filesystem.
80
+ *
81
+ * This is the canonical recovery path: even if `.pugi/index.json` is
82
+ * deleted or corrupt, we can always reconstruct it.
83
+ */
84
+ export function rebuildIndex(root) {
85
+ const events = readEvents(root);
86
+ const sessions = groupSessionsFromEvents(events);
87
+ const artifacts = scanArtifacts(root, events);
88
+ return {
89
+ schema: 1,
90
+ updatedAt: new Date().toISOString(),
91
+ artifacts,
92
+ sessions,
93
+ };
94
+ }
95
+ /**
96
+ * Append an artifact to the index, dedup by id. Touches `updatedAt`.
97
+ */
98
+ export function upsertArtifact(index, artifact) {
99
+ const next = { ...index };
100
+ const existing = next.artifacts.findIndex((a) => a.id === artifact.id);
101
+ if (existing >= 0) {
102
+ next.artifacts = next.artifacts.map((a, i) => (i === existing ? artifact : a));
103
+ }
104
+ else {
105
+ next.artifacts = [...next.artifacts, artifact];
106
+ }
107
+ next.updatedAt = new Date().toISOString();
108
+ // Attach to session bucket if we know the sessionId.
109
+ if (artifact.sessionId) {
110
+ next.sessions = next.sessions.map((session) => session.id === artifact.sessionId
111
+ ? {
112
+ ...session,
113
+ artifactIds: session.artifactIds.includes(artifact.id)
114
+ ? session.artifactIds
115
+ : [...session.artifactIds, artifact.id],
116
+ }
117
+ : session);
118
+ }
119
+ return next;
120
+ }
121
+ export function readEvents(root) {
122
+ const eventsPath = resolve(root, '.pugi', 'events.jsonl');
123
+ if (!existsSync(eventsPath))
124
+ return [];
125
+ const raw = readFileSync(eventsPath, 'utf8');
126
+ const out = [];
127
+ for (const line of raw.split('\n')) {
128
+ const trimmed = line.trim();
129
+ if (!trimmed)
130
+ continue;
131
+ try {
132
+ const parsed = auditEventSchema.safeParse(JSON.parse(trimmed));
133
+ if (parsed.success)
134
+ out.push(parsed.data);
135
+ }
136
+ catch {
137
+ // Tolerate corrupt single lines so the rest of the log keeps loading.
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ function groupSessionsFromEvents(events) {
143
+ const map = new Map();
144
+ for (const event of events) {
145
+ if (event.type !== 'session')
146
+ continue;
147
+ let session = map.get(event.sessionId);
148
+ if (!session) {
149
+ session = {
150
+ id: event.sessionId,
151
+ startedAt: event.timestamp,
152
+ endedAt: null,
153
+ commandCount: 0,
154
+ commands: [],
155
+ artifactIds: [],
156
+ };
157
+ map.set(event.sessionId, session);
158
+ }
159
+ if (event.name === 'command_started' && event.command) {
160
+ session.commandCount += 1;
161
+ session.commands.push({
162
+ command: event.command,
163
+ status: 'started',
164
+ timestamp: event.timestamp,
165
+ });
166
+ }
167
+ else if (event.name === 'command_completed' && event.command) {
168
+ session.commands.push({
169
+ command: event.command,
170
+ status: event.status === 'error' ? 'error' : 'success',
171
+ timestamp: event.timestamp,
172
+ });
173
+ session.endedAt = event.timestamp;
174
+ }
175
+ else if (event.name === 'created') {
176
+ session.startedAt = event.timestamp;
177
+ }
178
+ }
179
+ return [...map.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
180
+ }
181
+ function scanArtifacts(root, events) {
182
+ const artifactsDir = resolve(root, '.pugi', 'artifacts');
183
+ const handoffsDir = resolve(root, '.pugi', 'handoffs');
184
+ const out = [];
185
+ // Map artifact dir names to sessionIds via tool_call events (best-effort).
186
+ // Artifact ids are timestamped slugs (e.g. `2026-05-22T03-51-37-260Z-triple-review`);
187
+ // tool_call records the `inputSummary` which often contains the prompt.
188
+ // We attach sessionId by mtime proximity to the closest tool_call event.
189
+ const toolCallTimes = events
190
+ .filter((e) => e.type === 'tool_call')
191
+ .map((e) => ({ sessionId: e.sessionId, timestamp: Date.parse(e.timestamp) }))
192
+ .filter((entry) => Number.isFinite(entry.timestamp));
193
+ if (existsSync(artifactsDir)) {
194
+ for (const entry of readdirSync(artifactsDir, { withFileTypes: true })) {
195
+ if (!entry.isDirectory())
196
+ continue;
197
+ const dir = resolve(artifactsDir, entry.name);
198
+ const files = readdirSync(dir, { withFileTypes: true })
199
+ .filter((file) => file.isFile())
200
+ .map((file) => file.name)
201
+ .sort();
202
+ const stat = statSync(dir);
203
+ out.push({
204
+ id: entry.name,
205
+ kind: inferKindFromFiles(files),
206
+ path: relative(root, dir),
207
+ sessionId: nearestSessionId(stat.mtimeMs, toolCallTimes),
208
+ createdAt: stat.mtime.toISOString(),
209
+ files,
210
+ });
211
+ }
212
+ }
213
+ if (existsSync(handoffsDir)) {
214
+ for (const entry of readdirSync(handoffsDir, { withFileTypes: true })) {
215
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
216
+ continue;
217
+ const path = resolve(handoffsDir, entry.name);
218
+ const stat = statSync(path);
219
+ out.push({
220
+ id: entry.name.replace(/\.json$/, ''),
221
+ kind: 'handoff',
222
+ path: relative(root, path),
223
+ sessionId: nearestSessionId(stat.mtimeMs, toolCallTimes),
224
+ createdAt: stat.mtime.toISOString(),
225
+ files: [entry.name],
226
+ });
227
+ }
228
+ }
229
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
230
+ }
231
+ function inferKindFromFiles(files) {
232
+ if (files.some((f) => f === 'triple-review-request.json' || f === 'triple-review.md')) {
233
+ return 'triple-review';
234
+ }
235
+ if (files.includes('brief.md') && files.includes('execution-graph.json'))
236
+ return 'idea';
237
+ if (files.includes('plan.md'))
238
+ return 'plan';
239
+ if (files.includes('build.md'))
240
+ return 'build';
241
+ if (files.includes('review.md'))
242
+ return 'review';
243
+ if (files.includes('resume.md'))
244
+ return 'resume';
245
+ return 'other';
246
+ }
247
+ function nearestSessionId(mtimeMs, toolCallTimes) {
248
+ if (toolCallTimes.length === 0)
249
+ return null;
250
+ let best = null;
251
+ for (const entry of toolCallTimes) {
252
+ const delta = Math.abs(entry.timestamp - mtimeMs);
253
+ if (!best || delta < best.delta)
254
+ best = { sessionId: entry.sessionId, delta };
255
+ }
256
+ // Cap attribution to a 5-minute window so unrelated sessions do not collect
257
+ // each other's artifacts on long-lived repos.
258
+ return best && best.delta <= 5 * 60 * 1000 ? best.sessionId : null;
259
+ }
260
+ //# sourceMappingURL=index-store.js.map