@pugi/cli 0.1.0-beta.51 → 0.1.0-beta.53
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/dist/core/audit/audit-trail.js +194 -0
- package/dist/core/diagnostics/probes/engine-live.js +46 -0
- package/dist/core/engine/budgets.js +8 -3
- package/dist/core/engine/native-pugi.js +105 -0
- package/dist/core/hook-chains.js +410 -0
- package/dist/runtime/cli.js +5 -0
- package/dist/runtime/commands/doctor.js +23 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/bash.js +31 -0
- package/package.json +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-wide JSONL audit trail (Wave 7 Sprint 1 #21, 2026-05-29).
|
|
3
|
+
*
|
|
4
|
+
* Pugi already records every tool_call / tool_result in two places:
|
|
5
|
+
*
|
|
6
|
+
* 1. The global per-workspace log at `<workspace>/.pugi/events.jsonl`
|
|
7
|
+
* (audit-replay source of truth; see `core/session.ts`).
|
|
8
|
+
* 2. The per-session mirror at
|
|
9
|
+
* `<workspace>/.pugi/sessions/<sessionId>/events.jsonl`
|
|
10
|
+
* (operator-friendly per-run copy, see `native-pugi.ts`).
|
|
11
|
+
*
|
|
12
|
+
* Both live under the workspace directory and disappear when the
|
|
13
|
+
* operator wipes the workspace or runs many ephemeral sandboxes.
|
|
14
|
+
* What's missing is a TENANT-wide structured audit log: a single
|
|
15
|
+
* append-only NDJSON stream per (tenant, workspace) pair that the
|
|
16
|
+
* operator (or a SOC pipeline) can tail across every session over
|
|
17
|
+
* the lifetime of the host.
|
|
18
|
+
*
|
|
19
|
+
* Spec (Wave 7 #21):
|
|
20
|
+
*
|
|
21
|
+
* - Path: `~/.pugi/audit/<tenant>/<workspace-slug>-<hash>.jsonl`
|
|
22
|
+
* - One JSON line per event with shared shape:
|
|
23
|
+
* `{ ts, tenant, workspace, workspaceHash, event, sessionId, data }`
|
|
24
|
+
* - Events covered: `tool_call`, `tool_result`, `dispatch_start`,
|
|
25
|
+
* `dispatch_end`, `permission_denied`, `auto_compact`,
|
|
26
|
+
* `budget_exhausted`.
|
|
27
|
+
* - Append-only — no rotation logic. Operators wire `logrotate`
|
|
28
|
+
* themselves if they want size caps.
|
|
29
|
+
* - Opt-out: `PUGI_AUDIT_TRAIL_DISABLE=1`.
|
|
30
|
+
* - Failures NEVER throw. Audit MUST NOT break a dispatch.
|
|
31
|
+
* - Tenant fallback: when `PUGI_API_KEY` is unset, tenant is `local`.
|
|
32
|
+
*
|
|
33
|
+
* Why duplicate the per-session log on disk:
|
|
34
|
+
*
|
|
35
|
+
* The per-session mirror clusters by `sessionId` (one dir per run).
|
|
36
|
+
* To answer "what did this tenant DO across every session this week
|
|
37
|
+
* from this workspace" an operator otherwise has to glob hundreds of
|
|
38
|
+
* session dirs and merge by timestamp. The audit trail flattens that
|
|
39
|
+
* into one tail-able stream per (tenant, workspace) — same shape an
|
|
40
|
+
* ops pipeline would expect from a hosted log surface.
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { homedir } from 'node:os';
|
|
45
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
46
|
+
/**
|
|
47
|
+
* Opt-out env var. Mirrors the convention every other Pugi feature uses
|
|
48
|
+
* (`PUGI_BARE`, `PUGI_AGENTMEMORY_RECALL_ENABLED=false`, etc.).
|
|
49
|
+
* Operators set this when they pipe the CLI through a sandbox that
|
|
50
|
+
* already captures audit upstream and they want to skip the duplicate.
|
|
51
|
+
*/
|
|
52
|
+
export const PUGI_AUDIT_TRAIL_DISABLE_VAR = 'PUGI_AUDIT_TRAIL_DISABLE';
|
|
53
|
+
/**
|
|
54
|
+
* Tenant fallback used when the operator has not exported
|
|
55
|
+
* `PUGI_API_KEY`. The audit trail still flows — it just lives under
|
|
56
|
+
* `~/.pugi/audit/local/...` so a single-user workstation gets a useful
|
|
57
|
+
* forensic log without needing API-key plumbing.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCAL_TENANT_FALLBACK = 'local';
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize the workspace basename to a safe filesystem slug:
|
|
62
|
+
* lowercase a-z + 0-9 + `-`. Anything else collapses to `-`. We avoid
|
|
63
|
+
* the empty case (root workspace) by falling back to `workspace`.
|
|
64
|
+
*
|
|
65
|
+
* Why not a hash here too: the hash is appended separately so two
|
|
66
|
+
* workspaces with the same basename (e.g. two clones of the same repo
|
|
67
|
+
* sitting in different parent dirs) get distinct files. The slug is
|
|
68
|
+
* the human-readable half operators eyeball at `ls ~/.pugi/audit/...`.
|
|
69
|
+
*/
|
|
70
|
+
export function sanitizeWorkspaceSlug(workspaceRoot) {
|
|
71
|
+
const base = basename(resolve(workspaceRoot));
|
|
72
|
+
const sanitized = base
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
75
|
+
.replace(/-+/g, '-')
|
|
76
|
+
.replace(/^-|-$/g, '');
|
|
77
|
+
return sanitized.length > 0 ? sanitized : 'workspace';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Stable, anonymous workspace handle. We use the FIRST 8 hex of
|
|
81
|
+
* sha256(workspaceRoot). 8 hex = 32 bits = ~4 billion buckets, more
|
|
82
|
+
* than enough to disambiguate `~/code/foo` from `~/other/foo` on the
|
|
83
|
+
* same host without leaking the absolute path through the file name.
|
|
84
|
+
*
|
|
85
|
+
* The hash is over the RESOLVED path so symlink trickery cannot point
|
|
86
|
+
* two different audit streams at the same file by accident.
|
|
87
|
+
*/
|
|
88
|
+
export function computeWorkspaceHash(workspaceRoot) {
|
|
89
|
+
return createHash('sha256')
|
|
90
|
+
.update(resolve(workspaceRoot))
|
|
91
|
+
.digest('hex')
|
|
92
|
+
.slice(0, 8);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Derive the tenant slug from `PUGI_API_KEY`. We hash the key (sha256,
|
|
96
|
+
* 12 hex prefix) rather than emitting the raw key — the audit trail is
|
|
97
|
+
* a plaintext file on the local FS and the tenant slug shows up in
|
|
98
|
+
* every path under `~/.pugi/audit/`. A truncated hash is enough to
|
|
99
|
+
* cluster every (tenant, workspace) over time without leaking the key
|
|
100
|
+
* if the operator accidentally `tar`s their `~/.pugi` for support.
|
|
101
|
+
*
|
|
102
|
+
* The hash is purely a CLI-local clustering key — the runtime backend
|
|
103
|
+
* has its own (different) tenant identifier and never sees this.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTenant(env = process.env) {
|
|
106
|
+
const key = env.PUGI_API_KEY?.trim();
|
|
107
|
+
if (!key)
|
|
108
|
+
return LOCAL_TENANT_FALLBACK;
|
|
109
|
+
// 12 hex = 48 bits — enough disambiguation for any realistic per-host
|
|
110
|
+
// tenant cardinality; still short enough for operators to eyeball at
|
|
111
|
+
// `ls ~/.pugi/audit/`.
|
|
112
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 12);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the audit file path for a given (tenant, workspace) pair.
|
|
116
|
+
* Pure path arithmetic — the caller is responsible for `mkdir -p`
|
|
117
|
+
* before append (handled inside `writeAuditEvent`).
|
|
118
|
+
*/
|
|
119
|
+
export function resolveAuditPath(workspaceRoot, tenant, home = homedir()) {
|
|
120
|
+
const slug = sanitizeWorkspaceSlug(workspaceRoot);
|
|
121
|
+
const hash = computeWorkspaceHash(workspaceRoot);
|
|
122
|
+
return join(home, '.pugi', 'audit', tenant, `${slug}-${hash}.jsonl`);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Predicate: is the audit trail disabled via env opt-out?
|
|
126
|
+
*
|
|
127
|
+
* Accept `1`, `true`, `yes` (case-insensitive) as positive; anything
|
|
128
|
+
* else — including `0`, `false`, `''`, and the var being absent — keeps
|
|
129
|
+
* the trail enabled. Mirrors the convention used in `bare-mode/` and
|
|
130
|
+
* elsewhere in the CLI.
|
|
131
|
+
*/
|
|
132
|
+
export function isAuditDisabled(env = process.env) {
|
|
133
|
+
const raw = env[PUGI_AUDIT_TRAIL_DISABLE_VAR]?.trim().toLowerCase();
|
|
134
|
+
if (!raw)
|
|
135
|
+
return false;
|
|
136
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Append a single audit event to the per-tenant per-workspace NDJSON
|
|
140
|
+
* trail. Never throws — failures (FS unwritable, opt-out, malformed
|
|
141
|
+
* input) are silently swallowed so a misconfigured audit surface
|
|
142
|
+
* cannot break a dispatch. The engine adapter's existing per-session
|
|
143
|
+
* mirror remains intact as a redundant copy.
|
|
144
|
+
*
|
|
145
|
+
* Append-only: every call writes exactly one line. No rotation, no
|
|
146
|
+
* truncation. Operators wire `logrotate` if they want size caps.
|
|
147
|
+
*
|
|
148
|
+
* macOS hardening: we `mkdir -p` the parent dir on every call (cheap
|
|
149
|
+
* in practice — Node short-circuits when the dir exists) so a manual
|
|
150
|
+
* `rm -rf ~/.pugi/audit/<tenant>/` between runs does not turn the next
|
|
151
|
+
* append into ENOENT. The mode is `0o700` for the tenant dir and
|
|
152
|
+
* `0o600` for the JSONL file so curious users on a shared host cannot
|
|
153
|
+
* read another tenant's trail.
|
|
154
|
+
*/
|
|
155
|
+
export function writeAuditEvent(input) {
|
|
156
|
+
const env = input.env ?? process.env;
|
|
157
|
+
if (isAuditDisabled(env))
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
const tenant = (input.tenant?.trim() || resolveTenant(env)) || LOCAL_TENANT_FALLBACK;
|
|
161
|
+
const home = input.home ?? homedir();
|
|
162
|
+
const path = resolveAuditPath(input.workspaceRoot, tenant, home);
|
|
163
|
+
const now = input.now ? input.now() : new Date().toISOString();
|
|
164
|
+
const envelope = {
|
|
165
|
+
ts: now,
|
|
166
|
+
tenant,
|
|
167
|
+
workspace: sanitizeWorkspaceSlug(input.workspaceRoot),
|
|
168
|
+
workspaceHash: computeWorkspaceHash(input.workspaceRoot),
|
|
169
|
+
event: input.event,
|
|
170
|
+
sessionId: input.sessionId,
|
|
171
|
+
data: input.data,
|
|
172
|
+
};
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// mkdir failure is silent — the appendFileSync below will surface
|
|
178
|
+
// the real error and the outer catch swallows it. We still try
|
|
179
|
+
// the write so EEXIST on the dir (the only real path here) does
|
|
180
|
+
// not block the append.
|
|
181
|
+
}
|
|
182
|
+
appendFileSync(path, `${JSON.stringify(envelope)}\n`, {
|
|
183
|
+
encoding: 'utf8',
|
|
184
|
+
mode: 0o600,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Audit failures must NEVER break a dispatch. The session log + the
|
|
189
|
+
// per-session mirror under `<workspace>/.pugi/` remain as redundant
|
|
190
|
+
// surfaces. A future telemetry pass can surface the failure count
|
|
191
|
+
// via the doctor probe; for now silent no-op is the contract.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=audit-trail.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const LIVE_PROMPT = 'Reply with the single word OK.';
|
|
2
|
+
const TIMEOUT_MS = 15_000;
|
|
3
|
+
export async function probeEngineLive(ctx, deps) {
|
|
4
|
+
const apiKey = deps.resolveApiKey(ctx.env);
|
|
5
|
+
if (!apiKey) {
|
|
6
|
+
return { name: 'ENGINE LIVE', status: 'skipped', detail: 'no API key (run `pugi login` or set PUGI_API_KEY)' };
|
|
7
|
+
}
|
|
8
|
+
const apiUrl = deps.resolveApiUrl(ctx.env);
|
|
9
|
+
const startedAt = deps.now();
|
|
10
|
+
const url = (apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl) + '/api/pugi/engine';
|
|
11
|
+
try {
|
|
12
|
+
const res = await deps.fetchImpl(url, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
15
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
personaSlug: 'main',
|
|
18
|
+
command: 'explain',
|
|
19
|
+
messages: [{ role: 'user', content: LIVE_PROMPT }],
|
|
20
|
+
tools: [],
|
|
21
|
+
temperature: 0,
|
|
22
|
+
maxTokens: 32,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
const latencyMs = deps.now() - startedAt;
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const body = await res.text().catch(() => '');
|
|
28
|
+
return { name: 'ENGINE LIVE', status: 'error', detail: `engine returned HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`, latencyMs };
|
|
29
|
+
}
|
|
30
|
+
const json = (await res.json().catch(() => null));
|
|
31
|
+
if (!json)
|
|
32
|
+
return { name: 'ENGINE LIVE', status: 'error', detail: 'engine returned 2xx but body is not JSON', latencyMs };
|
|
33
|
+
const model = typeof json['model'] === 'string' ? json['model'] : 'unknown';
|
|
34
|
+
const content = typeof json['content'] === 'string' ? json['content'] : '';
|
|
35
|
+
if (!content.toLowerCase().includes('ok')) {
|
|
36
|
+
return { name: 'ENGINE LIVE', status: 'warn', detail: `round-trip OK via ${model} (${latencyMs}ms) but reply did not contain expected token; got "${content.slice(0, 80)}"`, latencyMs };
|
|
37
|
+
}
|
|
38
|
+
return { name: 'ENGINE LIVE', status: 'ok', detail: `round-trip OK via ${model} (${latencyMs}ms)`, latencyMs };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const latencyMs = deps.now() - startedAt;
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return { name: 'ENGINE LIVE', status: 'error', detail: `engine round-trip failed: ${message}`, latencyMs };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=engine-live.js.map
|
|
@@ -70,11 +70,16 @@ export function resolveAutoCompactConfig(settings) {
|
|
|
70
70
|
* them. Claude Code's `code` default is ~80k; matching that restores headroom.
|
|
71
71
|
*/
|
|
72
72
|
export const beta1DefaultBudgets = {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// 2026-05-29 bump (CEO #44, post-auto-compact #14 + prompt-cache #15):
|
|
74
|
+
// auto-compact at 75% threshold + cache_control on stable prefix mean
|
|
75
|
+
// real per-call token use is ~30-40% lower than legacy. Bump headroom
|
|
76
|
+
// so multi-file refactors no longer trip the cap. Anvil clamps per-call
|
|
77
|
+
// max_tokens to 128k (PR #710) so the engine envelope still safe.
|
|
78
|
+
fix: { maxTokens: 80_000, maxToolCalls: 20 },
|
|
79
|
+
code: { maxTokens: 120_000, maxToolCalls: 25 },
|
|
75
80
|
build: { maxTokens: 200_000, maxToolCalls: 30 },
|
|
76
81
|
plan: { maxTokens: 200_000, maxToolCalls: 8 },
|
|
77
|
-
explain: { maxTokens:
|
|
82
|
+
explain: { maxTokens: 60_000, maxToolCalls: 10 },
|
|
78
83
|
review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
|
|
79
84
|
};
|
|
80
85
|
/**
|
|
@@ -8,6 +8,7 @@ import { openSession, recordToolCall, recordToolResult } from '../session.js';
|
|
|
8
8
|
import { prewarmRealDispatch } from '../subagents/dispatcher.js';
|
|
9
9
|
import { resolveAutoCompactConfig, resolveBudget } from './budgets.js';
|
|
10
10
|
import { maybeCompact } from './auto-compact.js';
|
|
11
|
+
import { writeAuditEvent } from '../audit/audit-trail.js';
|
|
11
12
|
import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
|
|
12
13
|
import { personaSlugFor, systemPromptFor } from './prompts.js';
|
|
13
14
|
import { CancellationToken } from '../repl/cancellation.js';
|
|
@@ -228,6 +229,23 @@ export class NativePugiEngineAdapter {
|
|
|
228
229
|
type: 'status',
|
|
229
230
|
message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
|
|
230
231
|
});
|
|
232
|
+
// Wave 7 Sprint 1 #21 (2026-05-29): emit `dispatch_start` to the
|
|
233
|
+
// tenant-wide audit trail at `~/.pugi/audit/<tenant>/<slug>-<hash>
|
|
234
|
+
// .jsonl`. Append-only, never throws — a misconfigured audit
|
|
235
|
+
// surface must not block a dispatch. The per-session mirror under
|
|
236
|
+
// `.pugi/sessions/<id>/events.jsonl` remains as a redundant copy.
|
|
237
|
+
writeAuditEvent({
|
|
238
|
+
event: 'dispatch_start',
|
|
239
|
+
sessionId: session.id,
|
|
240
|
+
workspaceRoot: root,
|
|
241
|
+
data: {
|
|
242
|
+
kind,
|
|
243
|
+
promptLength: task.prompt.length,
|
|
244
|
+
maxToolCalls: budget.maxToolCalls,
|
|
245
|
+
maxTokens: budget.maxTokens,
|
|
246
|
+
model: this.options.model ?? null,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
231
249
|
// β5a R1+R4+R5+R6+P1 (2026-05-26): build the per-turn `<context>`
|
|
232
250
|
// prefix and apply the intent marker so the model sees:
|
|
233
251
|
// 1. cwd + open-files + per-dir-conventions block (R5+R6)
|
|
@@ -392,6 +410,18 @@ export class NativePugiEngineAdapter {
|
|
|
392
410
|
maxTokens: event.maxTokens,
|
|
393
411
|
gist: event.gist,
|
|
394
412
|
});
|
|
413
|
+
// Wave 7 #21: tenant-wide audit trail mirror.
|
|
414
|
+
writeAuditEvent({
|
|
415
|
+
event: 'auto_compact',
|
|
416
|
+
sessionId: session.id,
|
|
417
|
+
workspaceRoot: root,
|
|
418
|
+
data: {
|
|
419
|
+
droppedCount: event.droppedCount,
|
|
420
|
+
preUsedTokens: event.preUsedTokens,
|
|
421
|
+
postUsedTokens: event.postUsedTokens,
|
|
422
|
+
maxTokens: event.maxTokens,
|
|
423
|
+
},
|
|
424
|
+
});
|
|
395
425
|
},
|
|
396
426
|
onTurnStart: (turnIndex, messageCount) => {
|
|
397
427
|
const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
|
|
@@ -482,6 +512,20 @@ export class NativePugiEngineAdapter {
|
|
|
482
512
|
callId: call.id,
|
|
483
513
|
argsPreview: call.arguments.slice(0, 200),
|
|
484
514
|
});
|
|
515
|
+
// Wave 7 #21: tenant-wide audit trail mirror. Same payload
|
|
516
|
+
// shape as the session mirror but flattened so a `jq` query
|
|
517
|
+
// across all sessions for one (tenant, workspace) reads
|
|
518
|
+
// cleanly.
|
|
519
|
+
writeAuditEvent({
|
|
520
|
+
event: 'tool_call',
|
|
521
|
+
sessionId: session.id,
|
|
522
|
+
workspaceRoot: root,
|
|
523
|
+
data: {
|
|
524
|
+
tool: call.name,
|
|
525
|
+
callId: call.id,
|
|
526
|
+
argsPreview: call.arguments.slice(0, 200),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
485
529
|
},
|
|
486
530
|
onToolResult: (call, result) => {
|
|
487
531
|
const auditId = this.engineToolCallIds.get(call.id);
|
|
@@ -521,6 +565,18 @@ export class NativePugiEngineAdapter {
|
|
|
521
565
|
ok: result.ok,
|
|
522
566
|
summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
|
|
523
567
|
});
|
|
568
|
+
// Wave 7 #21: tenant-wide audit trail mirror.
|
|
569
|
+
writeAuditEvent({
|
|
570
|
+
event: 'tool_result',
|
|
571
|
+
sessionId: session.id,
|
|
572
|
+
workspaceRoot: root,
|
|
573
|
+
data: {
|
|
574
|
+
tool: call.name,
|
|
575
|
+
callId: call.id,
|
|
576
|
+
ok: result.ok,
|
|
577
|
+
summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
|
|
578
|
+
},
|
|
579
|
+
});
|
|
524
580
|
},
|
|
525
581
|
};
|
|
526
582
|
// β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
|
|
@@ -733,6 +789,19 @@ export class NativePugiEngineAdapter {
|
|
|
733
789
|
await loopPromise;
|
|
734
790
|
if (loopError !== null) {
|
|
735
791
|
const message = loopError instanceof Error ? loopError.message : String(loopError);
|
|
792
|
+
// Wave 7 #21: surface the crash to the audit trail before
|
|
793
|
+
// returning. Mirrors the `failed` arm of the structured path
|
|
794
|
+
// below so a SOC pipeline sees one `dispatch_end` per dispatch
|
|
795
|
+
// regardless of which code path produced it.
|
|
796
|
+
writeAuditEvent({
|
|
797
|
+
event: 'dispatch_end',
|
|
798
|
+
sessionId: session.id,
|
|
799
|
+
workspaceRoot: root,
|
|
800
|
+
data: {
|
|
801
|
+
status: 'crashed',
|
|
802
|
+
error: message,
|
|
803
|
+
},
|
|
804
|
+
});
|
|
736
805
|
yield {
|
|
737
806
|
type: 'result',
|
|
738
807
|
result: {
|
|
@@ -751,6 +820,12 @@ export class NativePugiEngineAdapter {
|
|
|
751
820
|
if (finalOutcome === null) {
|
|
752
821
|
// Defensive — should never hit. `runEngineLoop` always either
|
|
753
822
|
// resolves with an outcome or throws (and we catch that above).
|
|
823
|
+
writeAuditEvent({
|
|
824
|
+
event: 'dispatch_end',
|
|
825
|
+
sessionId: session.id,
|
|
826
|
+
workspaceRoot: root,
|
|
827
|
+
data: { status: 'no_outcome' },
|
|
828
|
+
});
|
|
754
829
|
yield {
|
|
755
830
|
type: 'result',
|
|
756
831
|
result: {
|
|
@@ -793,6 +868,36 @@ export class NativePugiEngineAdapter {
|
|
|
793
868
|
filesChanged: filesChangedList,
|
|
794
869
|
reason: finalOutcome.reason,
|
|
795
870
|
});
|
|
871
|
+
// Wave 7 #21: emit `dispatch_end` to the tenant-wide audit trail.
|
|
872
|
+
// When the loop tripped the per-command budget we ALSO emit a
|
|
873
|
+
// dedicated `budget_exhausted` row so a SOC query can filter on
|
|
874
|
+
// event type alone without parsing the `data.status` payload.
|
|
875
|
+
if (finalOutcome.status === 'budget_exhausted') {
|
|
876
|
+
writeAuditEvent({
|
|
877
|
+
event: 'budget_exhausted',
|
|
878
|
+
sessionId: session.id,
|
|
879
|
+
workspaceRoot: root,
|
|
880
|
+
data: {
|
|
881
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
882
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
883
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
884
|
+
reason: finalOutcome.reason ?? null,
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
writeAuditEvent({
|
|
889
|
+
event: 'dispatch_end',
|
|
890
|
+
sessionId: session.id,
|
|
891
|
+
workspaceRoot: root,
|
|
892
|
+
data: {
|
|
893
|
+
status: finalOutcome.status,
|
|
894
|
+
toolCallCount: finalOutcome.toolCallCount,
|
|
895
|
+
turnsUsed: finalOutcome.turnsUsed,
|
|
896
|
+
tokensUsed: finalOutcome.tokensUsed,
|
|
897
|
+
filesChangedCount: filesChangedList.length,
|
|
898
|
+
reason: finalOutcome.reason ?? null,
|
|
899
|
+
},
|
|
900
|
+
});
|
|
796
901
|
yield {
|
|
797
902
|
type: 'result',
|
|
798
903
|
result: {
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook chains — `PostToolUseFailure` + `TaskCompleted` first-class events.
|
|
3
|
+
*
|
|
4
|
+
* Wave 7 #24 (CEO P1, 2026-05-29). Pugi already had primitives for
|
|
5
|
+
* `PostToolUseFailure` in the legacy `core/hooks.ts` registry but no
|
|
6
|
+
* way to declare a **fallback chain** that fires automatically when a
|
|
7
|
+
* tool dispatch fails or when an entire `pugi <command>` dispatch
|
|
8
|
+
* completes. Claude Code exposes both events as first-class hook
|
|
9
|
+
* sources; this module wires them on the Pugi side.
|
|
10
|
+
*
|
|
11
|
+
* Why a fresh module rather than extending `core/hooks.ts`:
|
|
12
|
+
* - The legacy registry reads flat `hooks: [{event, match, run}]`
|
|
13
|
+
* arrays from `~/.pugi/hooks.json` + `.pugi/hooks.json`. Chains
|
|
14
|
+
* are declared in a nested `hooks: { EventName: [{matcher, run}] }`
|
|
15
|
+
* shape from `.pugi/settings.json` — a different file, different
|
|
16
|
+
* shape, and importantly a different opt-out grammar (per-chain
|
|
17
|
+
* `enabled: false`). Mixing the two readers would force every
|
|
18
|
+
* legacy caller to learn the chain shape.
|
|
19
|
+
* - Chains have richer payloads (TaskCompleted ships durationMs,
|
|
20
|
+
* toolCalls, filesChanged). Stuffing them through the legacy
|
|
21
|
+
* stdin/env contract would silently break v1 scripts that key on
|
|
22
|
+
* the existing payload shape.
|
|
23
|
+
* - Chain failures MUST NOT crash the dispatch (the model already
|
|
24
|
+
* finished). The legacy registry's `onFailure: 'block'` semantics
|
|
25
|
+
* would propagate the error — chains explicitly swallow it and
|
|
26
|
+
* log instead.
|
|
27
|
+
*
|
|
28
|
+
* The chain runner is intentionally self-contained: no dependency on
|
|
29
|
+
* `HookRegistry`, no trust-ledger gating (project settings are already
|
|
30
|
+
* trusted by virtue of being in the workspace's `.pugi/` dir, same as
|
|
31
|
+
* persona prompts), and a single `firePostToolUseFailureChain` /
|
|
32
|
+
* `fireTaskCompletedChain` entry point per event.
|
|
33
|
+
*
|
|
34
|
+
* Brand voice: ASCII only, no emoji, no em-dashes.
|
|
35
|
+
*/
|
|
36
|
+
import { spawn } from 'node:child_process';
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { loadSettings } from './settings.js';
|
|
39
|
+
/**
|
|
40
|
+
* Per-hook matcher. Both keys are optional and AND together — a hook
|
|
41
|
+
* with `matcher: { tool: 'write' }` fires for every PostToolUseFailure
|
|
42
|
+
* whose tool is `write`, regardless of command. A hook with no matcher
|
|
43
|
+
* at all (or `matcher: {}`) fires on every event of its kind.
|
|
44
|
+
*/
|
|
45
|
+
const chainMatcherSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
/** Compare against the failing tool name (PostToolUseFailure). */
|
|
48
|
+
tool: z.string().min(1).optional(),
|
|
49
|
+
/** Compare against the completed command name (TaskCompleted). */
|
|
50
|
+
command: z.string().min(1).optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
/**
|
|
54
|
+
* Single chain entry. `run` is an array so chain authors can express
|
|
55
|
+
* "do A, then B, then C" without needing shell `&&` chaining — the
|
|
56
|
+
* runner executes the list sequentially and short-circuits on the
|
|
57
|
+
* first non-zero exit (within ONE entry; the next entry in the chain
|
|
58
|
+
* is still attempted).
|
|
59
|
+
*
|
|
60
|
+
* Hard-coded constants:
|
|
61
|
+
* - Default timeout per command: 10s (matches Claude Code).
|
|
62
|
+
* - Max timeout: 60s (matches legacy hooks.ts cap).
|
|
63
|
+
* - Stream cap: 256KB per stream (smaller than legacy 1MB because
|
|
64
|
+
* chain hooks are post-hoc notifications, not blocking gates).
|
|
65
|
+
*/
|
|
66
|
+
const chainEntrySchema = z
|
|
67
|
+
.object({
|
|
68
|
+
matcher: chainMatcherSchema.optional(),
|
|
69
|
+
run: z.array(z.string().min(1)).min(1),
|
|
70
|
+
timeoutMs: z.number().int().positive().max(60_000).optional(),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
/**
|
|
74
|
+
* Per-event chain config. `enabled: false` short-circuits the whole
|
|
75
|
+
* chain (opt-out switch the operator can flip without deleting the
|
|
76
|
+
* entries). `entries` defaults to empty so the operator can declare
|
|
77
|
+
* `{ enabled: false }` to suppress a chain inherited from a parent
|
|
78
|
+
* config layer later.
|
|
79
|
+
*/
|
|
80
|
+
const chainConfigSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
enabled: z.boolean().default(true),
|
|
83
|
+
entries: z.array(chainEntrySchema).default([]),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
/**
|
|
87
|
+
* Settings-level `hooks` block. We accept BOTH:
|
|
88
|
+
* 1. The canonical nested form
|
|
89
|
+
* `hooks: { PostToolUseFailure: { enabled, entries: [...] } }`
|
|
90
|
+
* 2. The shorthand array form
|
|
91
|
+
* `hooks: { PostToolUseFailure: [...entries] }`
|
|
92
|
+
* — which the CEO spec uses as the example. The reader normalises
|
|
93
|
+
* it to the canonical form before handing back to callers.
|
|
94
|
+
*
|
|
95
|
+
* Both forms are passed through `parseHookChains` below so callers see
|
|
96
|
+
* one shape regardless of which the operator typed.
|
|
97
|
+
*/
|
|
98
|
+
const settingsHooksShape = z
|
|
99
|
+
.object({
|
|
100
|
+
PostToolUseFailure: z
|
|
101
|
+
.union([chainConfigSchema, z.array(chainEntrySchema)])
|
|
102
|
+
.optional(),
|
|
103
|
+
TaskCompleted: z
|
|
104
|
+
.union([chainConfigSchema, z.array(chainEntrySchema)])
|
|
105
|
+
.optional(),
|
|
106
|
+
})
|
|
107
|
+
.strict()
|
|
108
|
+
.optional();
|
|
109
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
110
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
111
|
+
const STREAM_CAP_BYTES = 256 * 1024;
|
|
112
|
+
/**
|
|
113
|
+
* Resolve chain config from settings. Accepts both the canonical
|
|
114
|
+
* `{ enabled, entries }` shape and the shorthand array form the CEO
|
|
115
|
+
* spec example uses.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveChain(settings, event) {
|
|
118
|
+
// The `hooks` block on settings is loaded via `parseHookChains`
|
|
119
|
+
// below — we re-parse on every call so a settings reload picks up
|
|
120
|
+
// edits without restart-loop accounting. Cost is one Zod parse per
|
|
121
|
+
// dispatch, dwarfed by the network round-trip the loop just finished.
|
|
122
|
+
const rawHooks = settings.hooks;
|
|
123
|
+
const parsed = settingsHooksShape.safeParse(rawHooks);
|
|
124
|
+
if (!parsed.success || !parsed.data) {
|
|
125
|
+
return { enabled: true, entries: [] };
|
|
126
|
+
}
|
|
127
|
+
const raw = parsed.data[event];
|
|
128
|
+
if (!raw)
|
|
129
|
+
return { enabled: true, entries: [] };
|
|
130
|
+
if (Array.isArray(raw)) {
|
|
131
|
+
return { enabled: true, entries: raw };
|
|
132
|
+
}
|
|
133
|
+
return raw;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse the `hooks` section out of a raw settings JSON value. Exposed
|
|
137
|
+
* for tests and the settings reload path. Returns the normalised shape
|
|
138
|
+
* (both events present, canonical `{enabled, entries}` form) so the
|
|
139
|
+
* caller can assert structure without re-running Zod.
|
|
140
|
+
*/
|
|
141
|
+
export function parseHookChains(rawHooks) {
|
|
142
|
+
const parsed = settingsHooksShape.safeParse(rawHooks);
|
|
143
|
+
const out = {
|
|
144
|
+
PostToolUseFailure: { enabled: true, entries: [] },
|
|
145
|
+
TaskCompleted: { enabled: true, entries: [] },
|
|
146
|
+
};
|
|
147
|
+
if (!parsed.success || !parsed.data)
|
|
148
|
+
return out;
|
|
149
|
+
const pf = parsed.data.PostToolUseFailure;
|
|
150
|
+
if (pf) {
|
|
151
|
+
out.PostToolUseFailure = Array.isArray(pf) ? { enabled: true, entries: pf } : pf;
|
|
152
|
+
}
|
|
153
|
+
const tc = parsed.data.TaskCompleted;
|
|
154
|
+
if (tc) {
|
|
155
|
+
out.TaskCompleted = Array.isArray(tc) ? { enabled: true, entries: tc } : tc;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Decide whether a chain entry matches a `PostToolUseFailure` payload.
|
|
161
|
+
* An entry with no matcher (or only the empty object) fires on every
|
|
162
|
+
* failure of its event kind.
|
|
163
|
+
*/
|
|
164
|
+
function matchesPostToolUseFailure(entry, payload) {
|
|
165
|
+
const m = entry.matcher;
|
|
166
|
+
if (!m || (!m.tool && !m.command))
|
|
167
|
+
return true;
|
|
168
|
+
if (m.command !== undefined) {
|
|
169
|
+
// `command` does not apply to PostToolUseFailure semantically;
|
|
170
|
+
// ignore it so a chain author who pastes both keys does not get
|
|
171
|
+
// a silent no-match. Documented in matcher schema comments.
|
|
172
|
+
}
|
|
173
|
+
if (m.tool !== undefined && m.tool !== payload.toolName)
|
|
174
|
+
return false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Decide whether a chain entry matches a `TaskCompleted` payload.
|
|
179
|
+
*/
|
|
180
|
+
function matchesTaskCompleted(entry, payload) {
|
|
181
|
+
const m = entry.matcher;
|
|
182
|
+
if (!m || (!m.tool && !m.command))
|
|
183
|
+
return true;
|
|
184
|
+
if (m.command !== undefined && m.command !== payload.command)
|
|
185
|
+
return false;
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Fire the `PostToolUseFailure` chain. Best-effort: a hook crash, a
|
|
190
|
+
* spawn error, or a timeout never propagates back to the caller. The
|
|
191
|
+
* caller observes the structured outcome and may log / surface it.
|
|
192
|
+
*
|
|
193
|
+
* Caller responsibility: the engine's tool-bridge invokes this AFTER
|
|
194
|
+
* the existing legacy `PostToolUseFailure` registry fire so legacy
|
|
195
|
+
* scripts and chain entries both run on the same failure (the legacy
|
|
196
|
+
* registry is the strict per-tool gate; chains are the fallback hook).
|
|
197
|
+
*/
|
|
198
|
+
export async function firePostToolUseFailureChain(workspaceRoot, payload, settingsOverride) {
|
|
199
|
+
const settings = settingsOverride ?? safeLoadSettings(workspaceRoot);
|
|
200
|
+
const config = resolveChain(settings, 'PostToolUseFailure');
|
|
201
|
+
if (!config.enabled) {
|
|
202
|
+
return { event: 'PostToolUseFailure', enabled: false, entries: [] };
|
|
203
|
+
}
|
|
204
|
+
const out = {
|
|
205
|
+
event: 'PostToolUseFailure',
|
|
206
|
+
enabled: true,
|
|
207
|
+
entries: [],
|
|
208
|
+
};
|
|
209
|
+
for (const entry of config.entries) {
|
|
210
|
+
const matched = matchesPostToolUseFailure(entry, payload);
|
|
211
|
+
if (!matched) {
|
|
212
|
+
out.entries.push({ matched: false, commands: [] });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const commands = await runChainEntry(entry, {
|
|
216
|
+
PUGI_HOOK_EVENT: 'PostToolUseFailure',
|
|
217
|
+
PUGI_HOOK_PAYLOAD: JSON.stringify(payload),
|
|
218
|
+
PUGI_HOOK_TOOL: payload.toolName,
|
|
219
|
+
PUGI_HOOK_EXIT_CODE: String(payload.exitCode),
|
|
220
|
+
});
|
|
221
|
+
out.entries.push({ matched: true, commands });
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Fire the `TaskCompleted` chain. Same best-effort semantics as the
|
|
227
|
+
* PostToolUseFailure chain above. Caller invokes this at the dispatch
|
|
228
|
+
* exit in `native-pugi.ts` regardless of completion status (the
|
|
229
|
+
* payload carries `exitCode` so the hook can branch on success vs
|
|
230
|
+
* failure).
|
|
231
|
+
*/
|
|
232
|
+
export async function fireTaskCompletedChain(workspaceRoot, payload, settingsOverride) {
|
|
233
|
+
const settings = settingsOverride ?? safeLoadSettings(workspaceRoot);
|
|
234
|
+
const config = resolveChain(settings, 'TaskCompleted');
|
|
235
|
+
if (!config.enabled) {
|
|
236
|
+
return { event: 'TaskCompleted', enabled: false, entries: [] };
|
|
237
|
+
}
|
|
238
|
+
const out = {
|
|
239
|
+
event: 'TaskCompleted',
|
|
240
|
+
enabled: true,
|
|
241
|
+
entries: [],
|
|
242
|
+
};
|
|
243
|
+
for (const entry of config.entries) {
|
|
244
|
+
const matched = matchesTaskCompleted(entry, payload);
|
|
245
|
+
if (!matched) {
|
|
246
|
+
out.entries.push({ matched: false, commands: [] });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const commands = await runChainEntry(entry, {
|
|
250
|
+
PUGI_HOOK_EVENT: 'TaskCompleted',
|
|
251
|
+
PUGI_HOOK_PAYLOAD: JSON.stringify(payload),
|
|
252
|
+
PUGI_HOOK_COMMAND: payload.command,
|
|
253
|
+
PUGI_HOOK_EXIT_CODE: String(payload.exitCode),
|
|
254
|
+
PUGI_HOOK_DURATION_MS: String(payload.durationMs),
|
|
255
|
+
PUGI_HOOK_TOOL_CALLS: String(payload.toolCalls),
|
|
256
|
+
});
|
|
257
|
+
out.entries.push({ matched: true, commands });
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/** Run every command in one chain entry sequentially. */
|
|
262
|
+
async function runChainEntry(entry, baseEnv) {
|
|
263
|
+
const timeoutMs = entry.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
264
|
+
const results = [];
|
|
265
|
+
for (const command of entry.run) {
|
|
266
|
+
try {
|
|
267
|
+
const result = await executeOne(command, timeoutMs, baseEnv);
|
|
268
|
+
results.push(result);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
// Spawn failure (binary missing, fork limit hit, etc). Swallow
|
|
272
|
+
// and record so the chain marches on to the next command. The
|
|
273
|
+
// outer fireXxxChain caller is wrapped in another try/catch in
|
|
274
|
+
// the integration callsite for defense in depth.
|
|
275
|
+
results.push({
|
|
276
|
+
command,
|
|
277
|
+
exitCode: -1,
|
|
278
|
+
durationMs: 0,
|
|
279
|
+
stdout: '',
|
|
280
|
+
stderr: `chain spawn error: ${error.message}`,
|
|
281
|
+
timedOut: false,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return results;
|
|
286
|
+
}
|
|
287
|
+
/** Spawn ONE shell command and capture the result. */
|
|
288
|
+
function executeOne(command, timeoutMs, baseEnv) {
|
|
289
|
+
return new Promise((resolvePromise) => {
|
|
290
|
+
const startedAt = Date.now();
|
|
291
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
292
|
+
env: { ...process.env, ...baseEnv },
|
|
293
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
294
|
+
});
|
|
295
|
+
let stdout = '';
|
|
296
|
+
let stderr = '';
|
|
297
|
+
let killedForTimeout = false;
|
|
298
|
+
let killedForStreamCap = false;
|
|
299
|
+
let sigKillTimer;
|
|
300
|
+
const enforceStreamCap = () => {
|
|
301
|
+
if (killedForStreamCap)
|
|
302
|
+
return;
|
|
303
|
+
if (stdout.length + stderr.length <= STREAM_CAP_BYTES)
|
|
304
|
+
return;
|
|
305
|
+
killedForStreamCap = true;
|
|
306
|
+
child.kill('SIGTERM');
|
|
307
|
+
if (!sigKillTimer) {
|
|
308
|
+
sigKillTimer = setTimeout(() => {
|
|
309
|
+
if (!child.killed)
|
|
310
|
+
child.kill('SIGKILL');
|
|
311
|
+
}, SIGKILL_GRACE_MS);
|
|
312
|
+
if (sigKillTimer.unref)
|
|
313
|
+
sigKillTimer.unref();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
child.stdout?.on('data', (chunk) => {
|
|
317
|
+
if (killedForStreamCap)
|
|
318
|
+
return;
|
|
319
|
+
stdout += chunk.toString('utf8');
|
|
320
|
+
enforceStreamCap();
|
|
321
|
+
});
|
|
322
|
+
child.stderr?.on('data', (chunk) => {
|
|
323
|
+
if (killedForStreamCap)
|
|
324
|
+
return;
|
|
325
|
+
stderr += chunk.toString('utf8');
|
|
326
|
+
enforceStreamCap();
|
|
327
|
+
});
|
|
328
|
+
// Close stdin so commands that block on read (cat, jq) do not hang
|
|
329
|
+
// the chain. Errors are swallowed because the child may have
|
|
330
|
+
// already exited before we write.
|
|
331
|
+
if (child.stdin) {
|
|
332
|
+
child.stdin.on('error', () => {
|
|
333
|
+
/* ignore EPIPE */
|
|
334
|
+
});
|
|
335
|
+
child.stdin.end();
|
|
336
|
+
}
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
killedForTimeout = true;
|
|
339
|
+
child.kill('SIGTERM');
|
|
340
|
+
sigKillTimer = setTimeout(() => {
|
|
341
|
+
if (!child.killed)
|
|
342
|
+
child.kill('SIGKILL');
|
|
343
|
+
}, SIGKILL_GRACE_MS);
|
|
344
|
+
if (sigKillTimer.unref)
|
|
345
|
+
sigKillTimer.unref();
|
|
346
|
+
}, timeoutMs);
|
|
347
|
+
if (timer.unref)
|
|
348
|
+
timer.unref();
|
|
349
|
+
child.on('error', (error) => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
if (sigKillTimer)
|
|
352
|
+
clearTimeout(sigKillTimer);
|
|
353
|
+
resolvePromise({
|
|
354
|
+
command,
|
|
355
|
+
exitCode: -1,
|
|
356
|
+
durationMs: Date.now() - startedAt,
|
|
357
|
+
stdout,
|
|
358
|
+
stderr: stderr || `hook spawn error: ${error.message}`,
|
|
359
|
+
timedOut: false,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
child.on('close', (code, signal) => {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
if (sigKillTimer)
|
|
365
|
+
clearTimeout(sigKillTimer);
|
|
366
|
+
const durationMs = Date.now() - startedAt;
|
|
367
|
+
let exitCode;
|
|
368
|
+
if (code !== null) {
|
|
369
|
+
exitCode = code;
|
|
370
|
+
}
|
|
371
|
+
else if (signal === 'SIGTERM') {
|
|
372
|
+
exitCode = -15;
|
|
373
|
+
}
|
|
374
|
+
else if (signal === 'SIGKILL') {
|
|
375
|
+
exitCode = -9;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
exitCode = -1;
|
|
379
|
+
}
|
|
380
|
+
resolvePromise({
|
|
381
|
+
command,
|
|
382
|
+
exitCode,
|
|
383
|
+
durationMs,
|
|
384
|
+
stdout,
|
|
385
|
+
stderr,
|
|
386
|
+
timedOut: killedForTimeout || killedForStreamCap,
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* `loadSettings` throws on schema-invalid configs (e.g. legacy
|
|
393
|
+
* settings shape with no `hooks` block at all parses fine; but a
|
|
394
|
+
* settings file that is outright broken would crash the chain fire).
|
|
395
|
+
* Chains MUST NOT crash the dispatch — swallow + log to stderr so the
|
|
396
|
+
* caller proceeds.
|
|
397
|
+
*/
|
|
398
|
+
function safeLoadSettings(root) {
|
|
399
|
+
try {
|
|
400
|
+
return loadSettings(root);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
process.stderr.write(`[pugi hook-chains] settings load failed: ${error.message}\n`);
|
|
404
|
+
// Build a minimal settings object that parses through the Zod
|
|
405
|
+
// schema. The shape is built by re-parsing an empty object which
|
|
406
|
+
// `loadSettings` already does for missing files.
|
|
407
|
+
return loadSettings('/this-path-does-not-exist-fallback');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
//# sourceMappingURL=hook-chains.js.map
|
package/dist/runtime/cli.js
CHANGED
|
@@ -1318,6 +1318,7 @@ function parseArgs(argv) {
|
|
|
1318
1318
|
// TTY). PUGI_NO_AUTO_* env vars provide a per-shell escape hatch
|
|
1319
1319
|
// without needing к thread the flag through every invocation.
|
|
1320
1320
|
noInit: process.env.PUGI_NO_AUTO_INIT === '1',
|
|
1321
|
+
live: false,
|
|
1321
1322
|
noLogin: process.env.PUGI_NO_AUTO_LOGIN === '1',
|
|
1322
1323
|
decompose: false,
|
|
1323
1324
|
// β-headless: --no-tools default OFF so existing flag-free invocations
|
|
@@ -1430,6 +1431,9 @@ function parseArgs(argv) {
|
|
|
1430
1431
|
// at the global level for consistency with --no-splash / --no-tool-stream.
|
|
1431
1432
|
flags.noDefaults = true;
|
|
1432
1433
|
}
|
|
1434
|
+
else if (arg === '--live') {
|
|
1435
|
+
flags.live = true;
|
|
1436
|
+
}
|
|
1433
1437
|
else if (arg === '--ascii-only') {
|
|
1434
1438
|
// Leak L33 — `pugi stickers --ascii-only` skips the Ink boxed
|
|
1435
1439
|
// renderer. Parsed globally so the dispatcher can pass the flag
|
|
@@ -2150,6 +2154,7 @@ async function doctor(_args, flags, _session) {
|
|
|
2150
2154
|
home: defaultDoctorHome(),
|
|
2151
2155
|
env: process.env,
|
|
2152
2156
|
json: flags.json,
|
|
2157
|
+
live: flags.live,
|
|
2153
2158
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2154
2159
|
});
|
|
2155
2160
|
}
|
|
@@ -53,6 +53,7 @@ import { probeBareMode } from '../../core/diagnostics/probes/bare-mode.js';
|
|
|
53
53
|
import { probePugiMdHierarchy } from '../../core/diagnostics/probes/pugi-md.js';
|
|
54
54
|
import { probeSandbox } from '../../core/diagnostics/probes/sandbox.js';
|
|
55
55
|
import { probeHooks } from '../../core/diagnostics/probes/hooks.js';
|
|
56
|
+
import { probeEngineLive } from '../../core/diagnostics/probes/engine-live.js';
|
|
56
57
|
/**
|
|
57
58
|
* Default API URL when no PUGI_API_URL env override is set. Mirrors
|
|
58
59
|
* the constant in `core/credentials.ts` (kept local to avoid an
|
|
@@ -250,6 +251,27 @@ export function buildDefaultProbes(ctx, options = {}) {
|
|
|
250
251
|
}),
|
|
251
252
|
},
|
|
252
253
|
];
|
|
254
|
+
// CEO P1 #22 (2026-05-29): ENGINE LIVE probe — end-to-end smoke
|
|
255
|
+
// against api.pugi.io. Only fires when `--live` is set so the
|
|
256
|
+
// default `pugi doctor` stays offline-safe.
|
|
257
|
+
if (options.live) {
|
|
258
|
+
probes.push({
|
|
259
|
+
name: 'ENGINE LIVE',
|
|
260
|
+
run: async () => probeEngineLive(ctx, {
|
|
261
|
+
resolveApiUrl: (env) => env['PUGI_API_URL'] ?? 'https://api.pugi.io',
|
|
262
|
+
resolveApiKey: (env) => {
|
|
263
|
+
const fromEnv = env['PUGI_API_KEY'];
|
|
264
|
+
if (fromEnv && fromEnv.length > 0)
|
|
265
|
+
return fromEnv;
|
|
266
|
+
const credential = resolveActiveCredential(env, ctx.home);
|
|
267
|
+
return credential?.apiKey ?? null;
|
|
268
|
+
},
|
|
269
|
+
fetchImpl,
|
|
270
|
+
now,
|
|
271
|
+
}),
|
|
272
|
+
timeoutMs: 20_000,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
253
275
|
return probes;
|
|
254
276
|
}
|
|
255
277
|
/**
|
|
@@ -266,6 +288,7 @@ export async function runDoctorCommand(ctx) {
|
|
|
266
288
|
const probes = buildDefaultProbes(probeCtx, {
|
|
267
289
|
...(ctx.liveSessionId ? { liveSessionId: ctx.liveSessionId } : {}),
|
|
268
290
|
...(ctx.denialTracking ? { denialTracking: ctx.denialTracking } : {}),
|
|
291
|
+
...(ctx.live ? { live: ctx.live } : {}),
|
|
269
292
|
});
|
|
270
293
|
const report = await runProbes(probes);
|
|
271
294
|
// Defensive recompute: even though runProbes already computed the
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.53');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tools/bash.js
CHANGED
|
@@ -33,6 +33,7 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
33
33
|
import { spawn, spawnSync } from 'node:child_process';
|
|
34
34
|
import { classifyBash } from '../core/bash-classifier.js';
|
|
35
35
|
import { evaluateBashPermission } from '../core/permission.js';
|
|
36
|
+
import { writeAuditEvent } from '../core/audit/audit-trail.js';
|
|
36
37
|
import { getJobRegistry, } from '../core/jobs/registry.js';
|
|
37
38
|
import { recordToolCall, recordToolResult } from '../core/session.js';
|
|
38
39
|
export const BASH_OUTPUT_CAP_BYTES = 32 * 1024;
|
|
@@ -95,6 +96,22 @@ export async function bashTool(input, ctx) {
|
|
|
95
96
|
if (decision.decision !== 'allow') {
|
|
96
97
|
const reason = `Permission ${decision.decision}: ${decision.reason}`;
|
|
97
98
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
99
|
+
// Wave 7 Sprint 1 #21 (2026-05-29): emit `permission_denied` to
|
|
100
|
+
// the tenant-wide audit trail. Truncate the cmd preview to 200
|
|
101
|
+
// chars so a long here-doc does not bloat the JSONL row; the
|
|
102
|
+
// session log keeps the full text for forensic replay.
|
|
103
|
+
writeAuditEvent({
|
|
104
|
+
event: 'permission_denied',
|
|
105
|
+
sessionId: ctx.session.id,
|
|
106
|
+
workspaceRoot: ctx.root,
|
|
107
|
+
data: {
|
|
108
|
+
tool: 'bash',
|
|
109
|
+
source,
|
|
110
|
+
decision: decision.decision,
|
|
111
|
+
reason: decision.reason,
|
|
112
|
+
cmdPreview: cmd.slice(0, 200),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
98
115
|
return {
|
|
99
116
|
stdout: '',
|
|
100
117
|
stderr: `Permission denied: ${decision.reason}`,
|
|
@@ -800,6 +817,20 @@ export function bashToolSync(input, ctx) {
|
|
|
800
817
|
if (decision.decision !== 'allow') {
|
|
801
818
|
const reason = `Permission ${decision.decision}: ${decision.reason}`;
|
|
802
819
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
820
|
+
// Wave 7 #21: mirror the async-path emission so sync callers
|
|
821
|
+
// (spawnSync fallback) produce the same tenant-wide audit trail.
|
|
822
|
+
writeAuditEvent({
|
|
823
|
+
event: 'permission_denied',
|
|
824
|
+
sessionId: ctx.session.id,
|
|
825
|
+
workspaceRoot: ctx.root,
|
|
826
|
+
data: {
|
|
827
|
+
tool: 'bash',
|
|
828
|
+
source,
|
|
829
|
+
decision: decision.decision,
|
|
830
|
+
reason: decision.reason,
|
|
831
|
+
cmdPreview: cmd.slice(0, 200),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
803
834
|
return {
|
|
804
835
|
stdout: '',
|
|
805
836
|
stderr: `Permission denied: ${decision.reason}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.53",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"turndown": "^7.2.4",
|
|
55
55
|
"undici": "^8.3.0",
|
|
56
56
|
"zod": "^3.23.0",
|
|
57
|
-
"@pugi/
|
|
58
|
-
"@pugi/
|
|
57
|
+
"@pugi/personas": "0.1.2",
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.53"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|