@pugi/cli 0.1.0-beta.23 → 0.1.0-beta.25
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/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/native-pugi.js +67 -3
- package/dist/core/engine/tool-bridge.js +123 -3
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +84 -0
- package/dist/core/repl/slash-commands.js +25 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +9 -0
- package/dist/runtime/cli.js +170 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/repl-splash-mascot.js +19 -7
- package/package.json +3 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine loop integration point for the six-tier compaction engine.
|
|
3
|
+
*
|
|
4
|
+
* `maybeCompactAfterTool` is the single function the engine loop calls
|
|
5
|
+
* after each tool result has been appended to the transcript. It:
|
|
6
|
+
*
|
|
7
|
+
* 1. Estimates current context-window pressure (transcript bytes
|
|
8
|
+
* against the model's budget, plus the static blocks).
|
|
9
|
+
* 2. Calls `selectTier` on the snapshot.
|
|
10
|
+
* 3. Runs the tier. Microcompact / cached_microcompact are sync;
|
|
11
|
+
* reactive_summary / session_memory / full_compaction / reset
|
|
12
|
+
* are async-shaped (the call returns before commit when run
|
|
13
|
+
* against a long transcript) but currently run inline — the
|
|
14
|
+
* engine loop is single-threaded today, so true backgrounding
|
|
15
|
+
* waits for the SSE consumer refactor in α5.7.
|
|
16
|
+
* 4. Runs invariant checks against the result. On any violation,
|
|
17
|
+
* emits `compaction.invariant_violated` and returns the
|
|
18
|
+
* pre-compaction transcript untouched.
|
|
19
|
+
* 5. On success, emits `compaction.completed` with reclaim numbers
|
|
20
|
+
* and returns the new transcript for the caller to adopt.
|
|
21
|
+
* 6. On no-op, emits `compaction.skipped` and returns the original.
|
|
22
|
+
*
|
|
23
|
+
* Why a separate file (not inlined into `native-pugi.ts`):
|
|
24
|
+
*
|
|
25
|
+
* Sprint α5.3 (feat/pugi-cli-hooks-lifecycle-m1-gap-c) is in flight
|
|
26
|
+
* and already modifies session.ts + tool-bridge + permission. Editing
|
|
27
|
+
* native-pugi.ts in this PR risks a merge conflict against α5.3's
|
|
28
|
+
* landing PR. Keeping the wiring as an exported helper means the
|
|
29
|
+
* one-line callsite in native-pugi.ts can be added in a tiny
|
|
30
|
+
* follow-up after both α5.3 and α5.5 have landed.
|
|
31
|
+
*
|
|
32
|
+
* Expected callsite in `apps/pugi-cli/src/core/engine/native-pugi.ts`,
|
|
33
|
+
* inside `onToolResult`:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const compactionOutcome = await maybeCompactAfterTool({
|
|
37
|
+
* session,
|
|
38
|
+
* transcript: currentTranscript,
|
|
39
|
+
* toolOutputs: recentToolOutputs,
|
|
40
|
+
* contextBudgetUsed: estimatedTokens,
|
|
41
|
+
* contextBudgetMax: budget.maxTokens,
|
|
42
|
+
* workspaceRoot: root,
|
|
43
|
+
* contextStaticHash: {
|
|
44
|
+
* instructionsHash,
|
|
45
|
+
* toolSchemaHash,
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* if (compactionOutcome.committed) {
|
|
49
|
+
* currentTranscript = compactionOutcome.newTranscript;
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
import { runCompaction, selectTier, } from '../context/compaction.js';
|
|
54
|
+
import { checkInvariants } from '../context/invariants.js';
|
|
55
|
+
import { emitCompactionCompleted, emitCompactionInvariantViolated, emitCompactionSkipped, emitCompactionStarted, } from '../context/compaction-events.js';
|
|
56
|
+
/**
|
|
57
|
+
* Engine-loop callback. See file header for the expected callsite shape.
|
|
58
|
+
*
|
|
59
|
+
* Contract:
|
|
60
|
+
* - Never throws. All errors degrade to `committed: false` with the
|
|
61
|
+
* original transcript and an event record.
|
|
62
|
+
* - On `committed: true`, the caller MUST adopt `newTranscript` as
|
|
63
|
+
* the live working transcript for the next model turn.
|
|
64
|
+
* - On `committed: false`, the caller MUST keep the input transcript
|
|
65
|
+
* and try again on the next tool turn (compaction will retry once
|
|
66
|
+
* pressure stays above threshold).
|
|
67
|
+
*/
|
|
68
|
+
export async function maybeCompactAfterTool(input) {
|
|
69
|
+
const compactionInput = {
|
|
70
|
+
sessionId: input.session.id,
|
|
71
|
+
contextBudgetUsed: input.contextBudgetUsed,
|
|
72
|
+
contextBudgetMax: input.contextBudgetMax,
|
|
73
|
+
toolOutputs: input.toolOutputs,
|
|
74
|
+
transcript: input.transcript,
|
|
75
|
+
workspaceRoot: input.workspaceRoot,
|
|
76
|
+
};
|
|
77
|
+
const tier = selectTier(compactionInput);
|
|
78
|
+
emitCompactionStarted(input.session, tier, {
|
|
79
|
+
budgetUsed: input.contextBudgetUsed,
|
|
80
|
+
budgetMax: input.contextBudgetMax,
|
|
81
|
+
});
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await runCompaction(compactionInput, tier);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
88
|
+
emitCompactionSkipped(input.session, tier, `compaction crashed: ${reason}`);
|
|
89
|
+
return {
|
|
90
|
+
committed: false,
|
|
91
|
+
tier,
|
|
92
|
+
newTranscript: input.transcript,
|
|
93
|
+
bytesReclaimed: 0,
|
|
94
|
+
newContextSize: byteSize(input.transcript),
|
|
95
|
+
violations: [],
|
|
96
|
+
skipped: true,
|
|
97
|
+
skipReason: `crashed: ${reason}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (result.skipped) {
|
|
101
|
+
emitCompactionSkipped(input.session, tier, result.skipReason || 'no work');
|
|
102
|
+
return {
|
|
103
|
+
committed: false,
|
|
104
|
+
tier,
|
|
105
|
+
newTranscript: input.transcript,
|
|
106
|
+
bytesReclaimed: 0,
|
|
107
|
+
newContextSize: byteSize(input.transcript),
|
|
108
|
+
violations: [],
|
|
109
|
+
skipped: true,
|
|
110
|
+
skipReason: result.skipReason,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Invariant gate: static-hash-unchanged is enforced by passing the
|
|
114
|
+
// same hashes in for `before` and `after` — compaction never touches
|
|
115
|
+
// static blocks, so the hashes are equal by construction. We pass
|
|
116
|
+
// both so the contract is explicit; if a future tier introduces a
|
|
117
|
+
// bug that overwrites static state, the check still catches it.
|
|
118
|
+
const violations = checkInvariants({
|
|
119
|
+
before: compactionInput,
|
|
120
|
+
after: result,
|
|
121
|
+
summaryText: result.summaryText,
|
|
122
|
+
staticHashBefore: input.contextStaticHash,
|
|
123
|
+
staticHashAfter: input.contextStaticHash,
|
|
124
|
+
});
|
|
125
|
+
if (violations.length > 0) {
|
|
126
|
+
for (const v of violations)
|
|
127
|
+
emitCompactionInvariantViolated(input.session, v);
|
|
128
|
+
return {
|
|
129
|
+
committed: false,
|
|
130
|
+
tier,
|
|
131
|
+
newTranscript: input.transcript,
|
|
132
|
+
bytesReclaimed: 0,
|
|
133
|
+
newContextSize: byteSize(input.transcript),
|
|
134
|
+
violations,
|
|
135
|
+
skipped: false,
|
|
136
|
+
skipReason: '',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
emitCompactionCompleted(input.session, tier, result.bytesReclaimed, result.newContextSize, result.artifactsCreated);
|
|
140
|
+
return {
|
|
141
|
+
committed: true,
|
|
142
|
+
tier,
|
|
143
|
+
newTranscript: result.newTranscript,
|
|
144
|
+
bytesReclaimed: result.bytesReclaimed,
|
|
145
|
+
newContextSize: result.newContextSize,
|
|
146
|
+
violations: [],
|
|
147
|
+
skipped: false,
|
|
148
|
+
skipReason: '',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function byteSize(transcript) {
|
|
152
|
+
return transcript.reduce((sum, t) => sum + Buffer.byteLength(t.content, 'utf8'), 0);
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=compaction-hook.js.map
|
|
@@ -260,6 +260,47 @@ export class NativePugiEngineAdapter {
|
|
|
260
260
|
ambientContextBlock = '';
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
|
+
// Leak L28 (2026-05-27): AST-light repo-map injection. We build a
|
|
264
|
+
// compact `## Repo map` block (capped at the formatter's default
|
|
265
|
+
// 8 KB ≈ 2K tokens) from the workspace source tree + splice it
|
|
266
|
+
// onto the system prompt alongside the ambient PUGI.md block.
|
|
267
|
+
// `--bare` skips this exactly like the PUGI.md walk — the engine
|
|
268
|
+
// sees nothing the operator did not explicitly hand it. The build
|
|
269
|
+
// is deferred к `setImmediate` semantics by being a sync call
|
|
270
|
+
// AFTER the boot probes; the cost is one stat per source file
|
|
271
|
+
// (the cache catches mtime-unchanged files и skips re-extraction).
|
|
272
|
+
// Failures are swallowed: repo-map is enrichment, never a gate.
|
|
273
|
+
let repoMapBlock = '';
|
|
274
|
+
if (!isBareMode()) {
|
|
275
|
+
try {
|
|
276
|
+
const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
|
|
277
|
+
const verdict = buildAndFormatRepoMap({
|
|
278
|
+
root,
|
|
279
|
+
// Boot path is best-effort: never refresh during engine boot
|
|
280
|
+
// (the operator can `pugi repo-map --refresh` manually). The
|
|
281
|
+
// cache freshness check catches every realistic edit pattern
|
|
282
|
+
// and avoids walking the tree on every engine invocation.
|
|
283
|
+
refresh: false,
|
|
284
|
+
// Persist the cache so the next boot reuses extracts. Engine
|
|
285
|
+
// boot runs on every command, so missing the persist would
|
|
286
|
+
// hot-loop the extractor on each invocation.
|
|
287
|
+
writeCache: true,
|
|
288
|
+
// Omit the formatter's section header — the system prompt
|
|
289
|
+
// already structures the ambient blocks, и a second `##`
|
|
290
|
+
// would fragment the prompt cache на a model-by-model basis.
|
|
291
|
+
omitHeader: false,
|
|
292
|
+
});
|
|
293
|
+
if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
|
|
294
|
+
repoMapBlock = verdict.format.text;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Any failure in the repo-map pipeline drops the block. The
|
|
299
|
+
// engine continues without enrichment — the failure mode is
|
|
300
|
+
// identical to the cold-boot path before L28 landed.
|
|
301
|
+
repoMapBlock = '';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
263
304
|
let traverseResult;
|
|
264
305
|
// Leak L22 (2026-05-27): `--bare` skips the parent-dir PUGI.md /
|
|
265
306
|
// AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
|
|
@@ -579,9 +620,17 @@ export class NativePugiEngineAdapter {
|
|
|
579
620
|
// nothing OR bare mode is on, `ambientContextBlock === ''`
|
|
580
621
|
// and the system prompt is unchanged — no leading blank
|
|
581
622
|
// line, no empty wrapper tag.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
623
|
+
//
|
|
624
|
+
// Leak L28 (2026-05-27): the `repoMapBlock` is splice'd
|
|
625
|
+
// between the ambient PUGI.md and the persona prompt so
|
|
626
|
+
// the model sees the workspace structure WITH the operator's
|
|
627
|
+
// ambient guidance fronting it. Empty blocks drop cleanly:
|
|
628
|
+
// `composeSystemPrompt` filters falsy entries before joining.
|
|
629
|
+
systemPrompt: composeSystemPrompt([
|
|
630
|
+
ambientContextBlock,
|
|
631
|
+
repoMapBlock,
|
|
632
|
+
systemPromptFor(kind),
|
|
633
|
+
]),
|
|
585
634
|
// β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
|
|
586
635
|
// applied above. Falls back to verbatim `task.prompt` when
|
|
587
636
|
// both the prefix block is empty AND the intent classifier
|
|
@@ -949,4 +998,19 @@ function relativeOrAbsolute(workspaceRoot, cwd) {
|
|
|
949
998
|
const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
|
|
950
999
|
return rel ?? absCwd;
|
|
951
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Leak L28 helper — splice multiple ambient blocks onto a persona
|
|
1003
|
+
* system prompt, dropping empty entries cleanly. The join character
|
|
1004
|
+
* is `\n\n` so each block renders as a discrete paragraph the model
|
|
1005
|
+
* can attend к without bleeding into its neighbour.
|
|
1006
|
+
*
|
|
1007
|
+
* Empty blocks return the base prompt unchanged — no leading
|
|
1008
|
+
* separators, no trailing whitespace. Mirrors the original
|
|
1009
|
+
* `ambientContextBlock ? ... : ...` shape so the single-block path
|
|
1010
|
+
* before L28 stays byte-identical (prompt cache friendliness).
|
|
1011
|
+
*/
|
|
1012
|
+
export function composeSystemPrompt(blocks) {
|
|
1013
|
+
const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
|
|
1014
|
+
return nonEmpty.join('\n\n');
|
|
1015
|
+
}
|
|
952
1016
|
//# sourceMappingURL=native-pugi.js.map
|
|
@@ -14,6 +14,7 @@ import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracki
|
|
|
14
14
|
import { stripInternalFields } from './strip-internal-fields.js';
|
|
15
15
|
import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
|
|
16
16
|
import { RetryBudget, RetryBudgetExhausted, hashArgs } from '../retry-budget/index.js';
|
|
17
|
+
import { runPostEditDiagnostics, } from '../lsp/post-edit-diagnostics.js';
|
|
17
18
|
/**
|
|
18
19
|
* Tool-bridge: turns the abstract tool registry into:
|
|
19
20
|
* 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
|
|
@@ -516,7 +517,7 @@ function requireString(obj, key) {
|
|
|
516
517
|
throw new Error(`tool argument "${key}" must be a string`);
|
|
517
518
|
}
|
|
518
519
|
export function buildExecutor(input) {
|
|
519
|
-
const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
|
|
520
|
+
const { kind, ctx, hooks, mvpHooksConfig, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
|
|
520
521
|
// Leak L31: per-cycle budget. Default to a fresh instance scoped to
|
|
521
522
|
// this executor's closure lifetime; tests pass their own.
|
|
522
523
|
const retryBudget = input.retryBudget ?? new RetryBudget();
|
|
@@ -687,6 +688,33 @@ export function buildExecutor(input) {
|
|
|
687
688
|
}
|
|
688
689
|
}
|
|
689
690
|
}
|
|
691
|
+
// Leak L12 MVP: fire `hooks-mvp.json` PreToolUse hooks. Distinct
|
|
692
|
+
// config file from the legacy `hooks.json` system so operator
|
|
693
|
+
// configs do not collide. Same blocking semantics — a non-zero
|
|
694
|
+
// exit from a hook declared `blocking: true` refuses the dispatch
|
|
695
|
+
// with `HOOK_BLOCKED:` sentinel. Bypass mode skips this surface
|
|
696
|
+
// identically to the legacy hooks block above.
|
|
697
|
+
if (mvpHooksConfig && sessionId && !hooksBypassed && !mvpHooksConfig.isEmpty()) {
|
|
698
|
+
const { fireHooks } = await import('../hooks/index.js');
|
|
699
|
+
const outcome = await fireHooks({
|
|
700
|
+
config: mvpHooksConfig,
|
|
701
|
+
event: 'PreToolUse',
|
|
702
|
+
payload: {
|
|
703
|
+
event: 'PreToolUse',
|
|
704
|
+
sessionId,
|
|
705
|
+
toolName: name,
|
|
706
|
+
toolInputSummary: hashArgs(argsRaw),
|
|
707
|
+
},
|
|
708
|
+
toolName: name,
|
|
709
|
+
workspaceRoot: ctx.root,
|
|
710
|
+
});
|
|
711
|
+
if (outcome.anyBlocked) {
|
|
712
|
+
const blocking = outcome.results.find((r) => r.blocked);
|
|
713
|
+
const sentinel = blocking?.blockSentinel ??
|
|
714
|
+
`HOOK_BLOCKED: PreToolUse MVP-hook refused ${name}`;
|
|
715
|
+
throw recordDenial(name, argsForTracking, sentinel);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
690
718
|
// β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
|
|
691
719
|
// PostToolUse / PostToolUseFailure hooks observe MCP calls just like
|
|
692
720
|
// native calls. The dispatcher does its own argument parsing — MCP
|
|
@@ -756,6 +784,14 @@ export function buildExecutor(input) {
|
|
|
756
784
|
};
|
|
757
785
|
try {
|
|
758
786
|
const result = await dispatch();
|
|
787
|
+
// Leak L15 (2026-05-27): post-edit LSP diagnostics. After a
|
|
788
|
+
// successful `edit` / `write` / `multi_edit`, ask the cached
|
|
789
|
+
// language server for diagnostics on the touched file(s) and
|
|
790
|
+
// append the result to the tool envelope so the model can
|
|
791
|
+
// self-correct in the same turn. Silent skip when the language
|
|
792
|
+
// is unsupported, no server is installed, or the request times
|
|
793
|
+
// out — agent throughput beats diagnostic recall.
|
|
794
|
+
const augmented = await appendPostEditDiagnostics(name, args, ctx, result);
|
|
759
795
|
if (hooks && sessionId && !hooksBypassed) {
|
|
760
796
|
const path = extractToolPath(name, argsRaw);
|
|
761
797
|
await hooks.fire({
|
|
@@ -763,10 +799,10 @@ export function buildExecutor(input) {
|
|
|
763
799
|
event: 'PostToolUse',
|
|
764
800
|
tool: name,
|
|
765
801
|
path,
|
|
766
|
-
payload: { tool: name, arguments: argsRaw, ok: true, result:
|
|
802
|
+
payload: { tool: name, arguments: argsRaw, ok: true, result: augmented.slice(0, 1024) },
|
|
767
803
|
});
|
|
768
804
|
}
|
|
769
|
-
return
|
|
805
|
+
return augmented;
|
|
770
806
|
}
|
|
771
807
|
catch (error) {
|
|
772
808
|
// Leak L6 — surface the PermissionDenied sentinel as a model-
|
|
@@ -1161,4 +1197,88 @@ function dispatchMultiEdit(args, ctx) {
|
|
|
1161
1197
|
const result = multiEdit(ctx, edits);
|
|
1162
1198
|
return JSON.stringify(result);
|
|
1163
1199
|
}
|
|
1200
|
+
/* ---------------------------- Leak L15 hook ---------------------------- */
|
|
1201
|
+
/**
|
|
1202
|
+
* Tool names that mutate workspace files. After a successful dispatch
|
|
1203
|
+
* of any of these, the L15 post-edit diagnostics hook fires. The set
|
|
1204
|
+
* is intentionally tight — `task_*` / `todo_write` write to ledger
|
|
1205
|
+
* files (not workspace source) so they stay out, and `bash` is too
|
|
1206
|
+
* coarse (a `bash` call can write any path, and we'd need to parse
|
|
1207
|
+
* the command to know which — out of scope for L15).
|
|
1208
|
+
*/
|
|
1209
|
+
const POST_EDIT_TOOLS = new Set(['edit', 'write', 'multi_edit']);
|
|
1210
|
+
/**
|
|
1211
|
+
* Append LSP diagnostics to the tool envelope after a successful
|
|
1212
|
+
* edit / write / multi_edit. Silent skip is the default — missing
|
|
1213
|
+
* binary, unsupported language, request timeout, and "no diagnostics"
|
|
1214
|
+
* all leave the envelope unchanged.
|
|
1215
|
+
*
|
|
1216
|
+
* Opt-in via `.pugi/settings.json::lsp.postEditDiagnostics = true`
|
|
1217
|
+
* OR `PUGI_LSP_POST_EDIT=1`. Off by default until dogfood validates
|
|
1218
|
+
* the cold-start cost vs the model-loop benefit (Leak L15).
|
|
1219
|
+
*/
|
|
1220
|
+
async function appendPostEditDiagnostics(name, args, ctx, result) {
|
|
1221
|
+
if (!POST_EDIT_TOOLS.has(name))
|
|
1222
|
+
return result;
|
|
1223
|
+
if (!isPostEditEnabled(ctx))
|
|
1224
|
+
return result;
|
|
1225
|
+
const paths = extractEditedPaths(name, args);
|
|
1226
|
+
if (paths.length === 0)
|
|
1227
|
+
return result;
|
|
1228
|
+
const tails = [];
|
|
1229
|
+
for (const filePath of paths) {
|
|
1230
|
+
const opts = {
|
|
1231
|
+
cwd: ctx.root,
|
|
1232
|
+
...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
|
|
1233
|
+
};
|
|
1234
|
+
try {
|
|
1235
|
+
const diag = await runPostEditDiagnostics(filePath, opts);
|
|
1236
|
+
if (!diag.skip) {
|
|
1237
|
+
tails.push(diag.tail);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
// Belt-and-suspenders: any unexpected throw from the hook is
|
|
1242
|
+
// swallowed. The model never blocks on LSP.
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (tails.length === 0)
|
|
1246
|
+
return result;
|
|
1247
|
+
return `${result}\n${tails.join('\n')}`;
|
|
1248
|
+
}
|
|
1249
|
+
function isPostEditEnabled(ctx) {
|
|
1250
|
+
const envFlag = process.env.PUGI_LSP_POST_EDIT;
|
|
1251
|
+
if (envFlag === '1' || envFlag === 'true')
|
|
1252
|
+
return true;
|
|
1253
|
+
if (envFlag === '0' || envFlag === 'false')
|
|
1254
|
+
return false;
|
|
1255
|
+
return ctx.settings.lsp?.postEditDiagnostics === true;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Pull the workspace-relative file path(s) the tool just touched.
|
|
1259
|
+
* Each branch mirrors the args shape its `dispatch*` handler reads;
|
|
1260
|
+
* a deformed args object yields an empty list so the hook silently
|
|
1261
|
+
* skips instead of throwing inside the augmentation layer.
|
|
1262
|
+
*/
|
|
1263
|
+
function extractEditedPaths(name, args) {
|
|
1264
|
+
if (name === 'edit' || name === 'write') {
|
|
1265
|
+
const path = args['path'];
|
|
1266
|
+
return typeof path === 'string' && path.length > 0 ? [path] : [];
|
|
1267
|
+
}
|
|
1268
|
+
if (name === 'multi_edit') {
|
|
1269
|
+
const edits = args['edits'];
|
|
1270
|
+
if (!Array.isArray(edits))
|
|
1271
|
+
return [];
|
|
1272
|
+
const seen = new Set();
|
|
1273
|
+
for (const entry of edits) {
|
|
1274
|
+
if (!entry || typeof entry !== 'object')
|
|
1275
|
+
continue;
|
|
1276
|
+
const file = entry['file'];
|
|
1277
|
+
if (typeof file === 'string' && file.length > 0)
|
|
1278
|
+
seen.add(file);
|
|
1279
|
+
}
|
|
1280
|
+
return Array.from(seen);
|
|
1281
|
+
}
|
|
1282
|
+
return [];
|
|
1283
|
+
}
|
|
1164
1284
|
//# sourceMappingURL=tool-bridge.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks MVP — typed event payloads (Leak L12).
|
|
3
|
+
*
|
|
4
|
+
* This module ships the MINIMAL FIRST PASS of the user-config hook
|
|
5
|
+
* matrix. Two lifecycle events out of the eventual 8 land here:
|
|
6
|
+
*
|
|
7
|
+
* - `SessionStart` — fired once when the REPL boots (`session.ts`).
|
|
8
|
+
* - `PreToolUse` — fired before each tool dispatch
|
|
9
|
+
* (`engine/tool-bridge.ts`). Non-zero exit from a
|
|
10
|
+
* hook with `blocking: true` aborts the dispatch.
|
|
11
|
+
*
|
|
12
|
+
* The remaining 6 events (`PostToolUse`, `UserPromptSubmit`, `Stop`,
|
|
13
|
+
* `SubagentStop`, `PreCompact`, `Notification`) are deferred to a
|
|
14
|
+
* fast-follow PR. The pattern established here — discriminated union
|
|
15
|
+
* payloads + registry-driven dispatch — is the reusable template.
|
|
16
|
+
*
|
|
17
|
+
* Design note (parallel surface): an older surface lives at
|
|
18
|
+
* `apps/pugi-cli/src/core/hooks.ts` and uses a flat-array `hooks: [{
|
|
19
|
+
* event, match, run }]` config shape with per-hook events. THIS module
|
|
20
|
+
* adopts the Claude Code-style nested `hooks: { EventName: [{ matcher,
|
|
21
|
+
* command }] }` config shape. The two surfaces co-exist intentionally
|
|
22
|
+
* for the MVP — they read different files (`~/.pugi/hooks.json` vs.
|
|
23
|
+
* `~/.pugi/hooks-mvp.json`) so operator configs do not collide. The
|
|
24
|
+
* fast-follow PR consolidates the two readers.
|
|
25
|
+
*
|
|
26
|
+
* Brand voice: ASCII only, no emoji, no em-dashes, no marketing prose.
|
|
27
|
+
*/
|
|
28
|
+
/** Events the MVP actually fires. The 6 deferred events live in the */
|
|
29
|
+
/** type but no integration point emits them yet. */
|
|
30
|
+
export const MVP_HOOK_EVENTS = [
|
|
31
|
+
'SessionStart',
|
|
32
|
+
'PreToolUse',
|
|
33
|
+
];
|
|
34
|
+
export const ALL_HOOK_EVENTS_V2 = [
|
|
35
|
+
'SessionStart',
|
|
36
|
+
'PreToolUse',
|
|
37
|
+
'PostToolUse',
|
|
38
|
+
'UserPromptSubmit',
|
|
39
|
+
'Stop',
|
|
40
|
+
'SubagentStop',
|
|
41
|
+
'PreCompact',
|
|
42
|
+
'Notification',
|
|
43
|
+
];
|
|
44
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the MVP hooks module (Leak L12).
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the registry, runner, and event types so callers can do
|
|
5
|
+
*
|
|
6
|
+
* import { loadHooksConfig, fireHooks } from '../core/hooks/index.js';
|
|
7
|
+
*
|
|
8
|
+
* without reaching into individual files. See `events.ts` for the
|
|
9
|
+
* scope note explaining why this MVP module co-exists with the older
|
|
10
|
+
* `src/core/hooks.ts` surface.
|
|
11
|
+
*/
|
|
12
|
+
export { DEFAULT_HOOK_TIMEOUT_MS, HooksConfig, MAX_HOOK_TIMEOUT_MS, defaultHooksMvpPath, isToolEvent, loadHooksConfig, matchesTool, } from './registry.js';
|
|
13
|
+
export { fireHooks } from './runner.js';
|
|
14
|
+
export { ALL_HOOK_EVENTS_V2, MVP_HOOK_EVENTS, } from './events.js';
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks MVP — registry (Leak L12, first pass).
|
|
3
|
+
*
|
|
4
|
+
* Reads `<home>/hooks-mvp.json` and validates its shape with Zod. The
|
|
5
|
+
* file uses the Claude Code-style nested config:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "hooks": {
|
|
9
|
+
* "SessionStart": [{ "command": "echo session-start" }],
|
|
10
|
+
* "PreToolUse": [{ "matcher": "bash", "command": "echo bash-pre", "blocking": true }]
|
|
11
|
+
* }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Schema constraints:
|
|
15
|
+
* - Each hook entry MUST have a `command` (non-empty string).
|
|
16
|
+
* - `matcher` is optional. Defaults to `*` (any tool / any payload).
|
|
17
|
+
* For tool events, `matcher` is compared against the tool name.
|
|
18
|
+
* For non-tool events (SessionStart), `matcher` is ignored.
|
|
19
|
+
* - `timeoutMs` is optional. Defaults to 30 000 ms (per task spec).
|
|
20
|
+
* Capped at 60 000 ms to prevent operator-defined deadlocks.
|
|
21
|
+
* - `blocking` is optional. When true AND the hook exits non-zero,
|
|
22
|
+
* the registry surfaces an `anyBlocked: true` outcome so the
|
|
23
|
+
* caller can refuse the originating action. Only honoured for
|
|
24
|
+
* `PreToolUse` in the MVP — other events log but do not block.
|
|
25
|
+
*
|
|
26
|
+
* Failure modes:
|
|
27
|
+
* - File missing -> the registry is `empty()`. `list()` returns []
|
|
28
|
+
* and `fire()` is a no-op. This matches the Claude Code default
|
|
29
|
+
* (hooks are opt-in).
|
|
30
|
+
* - File present but invalid JSON / fails schema -> `load()` throws.
|
|
31
|
+
* The CLI surface (`pugi hooks doctor`) reports the error
|
|
32
|
+
* verbatim so the operator can fix the config.
|
|
33
|
+
*
|
|
34
|
+
* Brand voice: ASCII only, no emoji, no em-dashes.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { resolve } from 'node:path';
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
import { ALL_HOOK_EVENTS_V2 } from './events.js';
|
|
41
|
+
/** Default per-hook timeout when the operator does not set `timeoutMs`. */
|
|
42
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
|
43
|
+
/** Hard upper bound on `timeoutMs`. Prevents config-defined deadlocks. */
|
|
44
|
+
export const MAX_HOOK_TIMEOUT_MS = 60_000;
|
|
45
|
+
const hookEntrySchema = z
|
|
46
|
+
.object({
|
|
47
|
+
/**
|
|
48
|
+
* Tool-name matcher. `*` matches any tool. Plain strings match
|
|
49
|
+
* exactly (no glob in the MVP — fast-follow widens to glob). Ignored
|
|
50
|
+
* for non-tool events such as `SessionStart`.
|
|
51
|
+
*/
|
|
52
|
+
matcher: z.string().min(1).optional(),
|
|
53
|
+
/** Shell command. Spawned via `/bin/sh -c <command>`. */
|
|
54
|
+
command: z.string().min(1),
|
|
55
|
+
/** Per-hook timeout override. Defaults to 30 000 ms. */
|
|
56
|
+
timeoutMs: z.number().int().positive().max(MAX_HOOK_TIMEOUT_MS).optional(),
|
|
57
|
+
/**
|
|
58
|
+
* When true, a non-zero exit code from this hook blocks the
|
|
59
|
+
* originating action (currently `PreToolUse` only). Other events
|
|
60
|
+
* log the exit but do not block.
|
|
61
|
+
*/
|
|
62
|
+
blocking: z.boolean().optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
const hookEventEnum = z.enum([
|
|
66
|
+
'SessionStart',
|
|
67
|
+
'PreToolUse',
|
|
68
|
+
'PostToolUse',
|
|
69
|
+
'UserPromptSubmit',
|
|
70
|
+
'Stop',
|
|
71
|
+
'SubagentStop',
|
|
72
|
+
'PreCompact',
|
|
73
|
+
'Notification',
|
|
74
|
+
]);
|
|
75
|
+
const hooksFileSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
hooks: z.record(hookEventEnum, z.array(hookEntrySchema)).default({}),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
/** Default config file location — `~/.pugi/hooks-mvp.json`. */
|
|
81
|
+
export function defaultHooksMvpPath(home) {
|
|
82
|
+
const root = home ?? process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
83
|
+
return resolve(root, 'hooks-mvp.json');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* In-memory snapshot of the operator's `hooks-mvp.json`. Construct
|
|
87
|
+
* via `loadHooksConfig(path)` — `new HooksConfig()` is intentionally
|
|
88
|
+
* not exported so all production code paths go through the loader.
|
|
89
|
+
*/
|
|
90
|
+
export class HooksConfig {
|
|
91
|
+
path;
|
|
92
|
+
entries;
|
|
93
|
+
constructor(path, entries) {
|
|
94
|
+
this.path = path;
|
|
95
|
+
this.entries = entries;
|
|
96
|
+
}
|
|
97
|
+
/** Absolute path of the config file this snapshot was loaded from. */
|
|
98
|
+
configPath() {
|
|
99
|
+
return this.path;
|
|
100
|
+
}
|
|
101
|
+
/** All hooks declared for a given event. Returns [] when none. */
|
|
102
|
+
list(event) {
|
|
103
|
+
return this.entries[event] ?? [];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Hooks that match the (event, toolName?) tuple. For tool events
|
|
107
|
+
* (`PreToolUse`), `matcher` is compared against the tool name with
|
|
108
|
+
* `*` matching any. For non-tool events, all entries are returned
|
|
109
|
+
* regardless of `matcher`.
|
|
110
|
+
*/
|
|
111
|
+
listMatching(event, toolName) {
|
|
112
|
+
const all = this.list(event);
|
|
113
|
+
if (!isToolEvent(event))
|
|
114
|
+
return all;
|
|
115
|
+
return all.filter((entry) => matchesTool(entry.matcher, toolName));
|
|
116
|
+
}
|
|
117
|
+
/** Flat list of (event, entry) pairs across every configured event. */
|
|
118
|
+
flatten() {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const event of ALL_HOOK_EVENTS_V2) {
|
|
121
|
+
for (const entry of this.list(event)) {
|
|
122
|
+
out.push({ event, entry });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** True iff at least one hook is registered for any event. */
|
|
128
|
+
isEmpty() {
|
|
129
|
+
return this.flatten().length === 0;
|
|
130
|
+
}
|
|
131
|
+
/** A no-op snapshot used when the config file is absent. */
|
|
132
|
+
static empty(path) {
|
|
133
|
+
return new HooksConfig(path, {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Load + validate `hooks-mvp.json`. Returns a no-op snapshot when the
|
|
138
|
+
* file is absent. Throws on invalid JSON or schema violations — the
|
|
139
|
+
* caller is expected to surface the error to the operator via
|
|
140
|
+
* `pugi hooks doctor`.
|
|
141
|
+
*
|
|
142
|
+
* Contract (non-null invariant): this function ALWAYS returns a
|
|
143
|
+
* `HooksConfig` instance. It never returns `null` / `undefined`. When
|
|
144
|
+
* the config file is missing, callers receive `HooksConfig.empty(path)`
|
|
145
|
+
* — a truthy snapshot for which `isEmpty()` returns `true` and `list()`
|
|
146
|
+
* returns `[]`. Callers may safely chain `.isEmpty()` without a null
|
|
147
|
+
* guard. Asserted by `registry-empty.spec.ts`.
|
|
148
|
+
*/
|
|
149
|
+
export function loadHooksConfig(pathOverride) {
|
|
150
|
+
const path = pathOverride ?? defaultHooksMvpPath();
|
|
151
|
+
if (!existsSync(path)) {
|
|
152
|
+
return HooksConfig.empty(path);
|
|
153
|
+
}
|
|
154
|
+
let raw;
|
|
155
|
+
try {
|
|
156
|
+
raw = readFileSync(path, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new Error(`pugi hooks: cannot read ${path}: ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw new Error(`pugi hooks: ${path} is not valid JSON: ${error.message}`);
|
|
167
|
+
}
|
|
168
|
+
const result = hooksFileSchema.safeParse(parsed);
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
const issues = result.error.issues
|
|
171
|
+
.map((issue) => `${issue.path.join('.') || '<root>'} ${issue.message}`)
|
|
172
|
+
.join('; ');
|
|
173
|
+
throw new Error(`pugi hooks: ${path} failed schema validation: ${issues}`);
|
|
174
|
+
}
|
|
175
|
+
// Zod's `z.record(enum, value)` returns `Partial<Record<...>>` shape
|
|
176
|
+
// — keys that the operator did not include are `undefined`. Coerce
|
|
177
|
+
// explicitly into the same shape `HooksConfig` expects.
|
|
178
|
+
const entries = {};
|
|
179
|
+
for (const event of ALL_HOOK_EVENTS_V2) {
|
|
180
|
+
const list = result.data.hooks[event];
|
|
181
|
+
if (list && list.length > 0) {
|
|
182
|
+
entries[event] = list;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return new HooksConfig(path, entries);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `isToolEvent(event)` -> true for events where `matcher` is compared
|
|
189
|
+
* against the tool name. SessionStart / Stop / Notification / etc. do
|
|
190
|
+
* not have an associated tool so matcher is ignored.
|
|
191
|
+
*/
|
|
192
|
+
export function isToolEvent(event) {
|
|
193
|
+
return event === 'PreToolUse' || event === 'PostToolUse';
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Tool-name match grammar for the MVP. Intentionally narrow:
|
|
197
|
+
* - matcher missing or `*` -> matches any tool name (and the
|
|
198
|
+
* `bash`/`read`/... shape).
|
|
199
|
+
* - matcher === toolName -> exact match.
|
|
200
|
+
*
|
|
201
|
+
* Fast-follow widens this to glob via `picomatch` so operators can
|
|
202
|
+
* write `mcp__*` patterns. Deliberately not pulling in a glob lib for
|
|
203
|
+
* the MVP — the narrow grammar is enough to land the surface and the
|
|
204
|
+
* test matrix stays small.
|
|
205
|
+
*/
|
|
206
|
+
export function matchesTool(matcher, toolName) {
|
|
207
|
+
if (!matcher || matcher === '*')
|
|
208
|
+
return true;
|
|
209
|
+
if (!toolName)
|
|
210
|
+
return false;
|
|
211
|
+
return matcher === toolName;
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=registry.js.map
|