@pugi/cli 0.1.0-beta.30 → 0.1.0-beta.35

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 (45) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/onboarding/ensure-initialized.js +133 -0
  10. package/dist/core/prd-check/session-review.js +557 -0
  11. package/dist/core/repl/session.js +419 -15
  12. package/dist/core/repl/slash-commands.js +82 -7
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +463 -13
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/permissions.js +23 -0
  21. package/dist/runtime/commands/prd-check.js +53 -3
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { PERMISSION_MODES, PERMISSION_MODE_GLOSS, } from '../core/permissions/index.js';
5
+ /**
6
+ * Build the rendered rows from the canonical mode list. We map the
7
+ * 1-line gloss → row hint so `/permissions` and the picker stay in
8
+ * sync — a change to `PERMISSION_MODE_GLOSS` shows up here too.
9
+ */
10
+ const ITEMS = PERMISSION_MODES.map((mode) => ({
11
+ mode,
12
+ title: titleCase(mode),
13
+ hint: PERMISSION_MODE_GLOSS[mode],
14
+ }));
15
+ function titleCase(mode) {
16
+ return mode.charAt(0).toUpperCase() + mode.slice(1);
17
+ }
18
+ /**
19
+ * Resolve the initial cursor — prefer the explicit `initialIndex`
20
+ * (spec / test) when provided, otherwise highlight the row matching
21
+ * the active mode so the operator opens the picker with their
22
+ * current selection focused.
23
+ */
24
+ function resolveInitialIndex(currentMode, override) {
25
+ if (typeof override === 'number') {
26
+ return Math.min(Math.max(override, 0), ITEMS.length - 1);
27
+ }
28
+ const idx = ITEMS.findIndex((item) => item.mode === currentMode);
29
+ return idx >= 0 ? idx : 0;
30
+ }
31
+ export function PermissionsPicker(props) {
32
+ const [index, setIndex] = useState(resolveInitialIndex(props.currentMode, props.initialIndex));
33
+ useInput((input, key) => {
34
+ if (key.upArrow || input === 'k') {
35
+ setIndex((current) => (current === 0 ? ITEMS.length - 1 : current - 1));
36
+ return;
37
+ }
38
+ if (key.downArrow || input === 'j') {
39
+ setIndex((current) => (current === ITEMS.length - 1 ? 0 : current + 1));
40
+ return;
41
+ }
42
+ if (key.return) {
43
+ const selected = ITEMS[index];
44
+ if (selected)
45
+ props.onSelect(selected.mode);
46
+ return;
47
+ }
48
+ if (key.escape || input === 'q') {
49
+ props.onCancel();
50
+ return;
51
+ }
52
+ // Number shortcuts mirror the legacy text table order.
53
+ if (input === '1')
54
+ props.onSelect('plan');
55
+ if (input === '2')
56
+ props.onSelect('ask');
57
+ if (input === '3')
58
+ props.onSelect('allow');
59
+ if (input === '4')
60
+ props.onSelect('bypass');
61
+ });
62
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Permission mode" }), _jsx(Text, { dimColor: true, children: ` (current: ${props.currentMode} — ${props.sourceLabel})` })] }), props.firstRun ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "First time? Mode = Ask \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E. Use /permissions \u043A change later." }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: ITEMS.map((item, itemIndex) => {
63
+ const isSelected = itemIndex === index;
64
+ const isCurrent = item.mode === props.currentMode;
65
+ return (_jsx(PickerRow, { isSelected: isSelected, isCurrent: isCurrent, title: item.title, hint: item.hint }, item.mode));
66
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '↑/↓ select Enter confirm Esc cancel' }) })] }));
67
+ }
68
+ function PickerRow({ isSelected, isCurrent, title, hint, }) {
69
+ // Arrow glyph + padded title so highlighted and dim rows share
70
+ // column alignment. A trailing ` ●` marks the currently-effective
71
+ // mode (separate from cursor focus) so an operator instantly sees
72
+ // which row is "what I have now" vs "what I'm hovering".
73
+ const indicator = isSelected ? '▸ ' : ' ';
74
+ const padded = title.padEnd(10, ' ');
75
+ const currentMarker = isCurrent ? ' ●' : ' ';
76
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [indicator, padded] }), _jsx(Text, { color: isCurrent ? 'green' : undefined, children: currentMarker }), _jsx(Text, { dimColor: true, children: ` ${hint}` })] }));
77
+ }
78
+ //# sourceMappingURL=permissions-picker.js.map
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render } from 'ink';
3
3
  import { DeviceFlow } from './device-flow.js';
4
4
  import { LoginPicker } from './login-picker.js';
5
+ import { PermissionsPicker } from './permissions-picker.js';
5
6
  import { Splash } from './splash.js';
6
7
  import { collectSplashData } from './splash-data.js';
7
8
  /**
@@ -66,6 +67,40 @@ export function renderLoginPicker(apiUrl) {
66
67
  }));
67
68
  });
68
69
  }
70
+ /**
71
+ * Sentinel thrown when the operator dismisses the permissions picker
72
+ * via Esc / q. Mirrors `LoginCancelledError` — the host catches and
73
+ * prints a one-line abort message; no mode flip lands.
74
+ */
75
+ export class PermissionsPickerCancelledError extends Error {
76
+ constructor() {
77
+ super('Permissions picker cancelled');
78
+ this.name = 'PermissionsPickerCancelledError';
79
+ }
80
+ }
81
+ export function renderPermissionsPicker(options) {
82
+ return new Promise((resolveMode, rejectMode) => {
83
+ let settled = false;
84
+ const finish = (cb) => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ instance.unmount();
89
+ setImmediate(cb);
90
+ };
91
+ const instance = render(React.createElement(PermissionsPicker, {
92
+ currentMode: options.currentMode,
93
+ sourceLabel: options.sourceLabel,
94
+ firstRun: options.firstRun ?? false,
95
+ onSelect: (mode) => {
96
+ finish(() => resolveMode(mode));
97
+ },
98
+ onCancel: () => {
99
+ finish(() => rejectMode(new PermissionsPickerCancelledError()));
100
+ },
101
+ }));
102
+ });
103
+ }
69
104
  /**
70
105
  * Mount `<DeviceFlow />` on a TTY and return a handle the host uses to
71
106
  * drive the frame. Mirrors `renderLoginPicker`'s lifecycle: we
@@ -32,7 +32,7 @@ export function StatusBar(props) {
32
32
  // per-brief `elapsedLabel` on the row below.
33
33
  const costRow = renderCostMeterRow(props.sessionTokensIn ?? 0, props.sessionTokensOut ?? 0, props.sessionCostUsd ?? 0, props.sessionStartedAtEpochMs, now);
34
34
  const deltaFlash = renderTurnDeltaFlash(props.lastTurnDelta, now);
35
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `↑ ${costRow.tokensInLabel}` }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: `↓ ${costRow.tokensOutLabel}` }), _jsx(Text, { dimColor: true, children: ` · ` }), _jsx(Text, { bold: true, children: costRow.costLabel }), _jsx(Text, { dimColor: true, children: ` · ${costRow.elapsedLabel}` }), deltaFlash ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "green", children: deltaFlash })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
35
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `↑ ${costRow.tokensInLabel}` }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: `↓ ${costRow.tokensOutLabel}` }), _jsx(Text, { dimColor: true, children: ` · ` }), _jsx(Text, { bold: true, children: costRow.costLabel }), _jsx(Text, { dimColor: true, children: ` · ${costRow.elapsedLabel}` }), deltaFlash ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "green", children: deltaFlash })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` }), typeof props.externalDispatchCount === 'number' && props.externalDispatchCount > 0 ? (_jsx(Text, { color: "yellow", children: ` · ${props.externalDispatchCount} dispatch${props.externalDispatchCount === 1 ? '' : 'es'} active. /cancel к manage.` })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
36
36
  }
37
37
  /**
38
38
  * α7 cost-meter sprint — assemble the cost-meter row labels. Pure helper
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { RESULT_PREVIEW_MAX_CHARS, STREAMING_DELTA_MAX_CHARS, } from '../core/repl/session.js';
3
4
  const DEFAULT_COLLAPSE_THRESHOLD = 5;
4
5
  const DEFAULT_MAX_ROWS = 8;
5
6
  export function ToolStreamPane(props) {
@@ -28,7 +29,38 @@ function ToolCallRow({ call, collapseThreshold, }) {
28
29
  const label = formatToolLabel(call.tool, call.args);
29
30
  const summary = formatSummary(call);
30
31
  const showHint = (call.resultLines ?? 0) > collapseThreshold;
31
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: ' ' }), _jsx(Text, { bold: true, children: label }), _jsx(Text, { dimColor: true, children: ` ${summary}` }), showHint ? (_jsx(Text, { dimColor: true, children: ` · ${call.resultLines} lines, Ctrl+O to expand` })) : null] }));
32
+ // Wave 6 small-CC-parity batch (2026-05-27): on a `running` row,
33
+ // surface the rolling streaming delta as a dim inline preview after
34
+ // the label. On a completed row, the same slot carries the
35
+ // `resultPreview` quoted head. Either way the row stays single-line —
36
+ // both fields are clamped к their respective char ceilings upstream.
37
+ const inlineTail = call.status === 'running'
38
+ ? call.streamingDelta
39
+ : call.resultPreview;
40
+ // Error rows: render the label too in red so the eye lands on the
41
+ // failure even при peripheral attention. The other states keep the
42
+ // glyph as the only color signal.
43
+ const labelColor = call.status === 'error' ? 'red' : undefined;
44
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: ' ' }), _jsx(Text, { bold: true, color: labelColor, children: label }), _jsx(Text, { dimColor: true, children: ` ${summary}` }), inlineTail ? (_jsx(Text, { dimColor: true, children: ` ${formatInlineTail(call.status, inlineTail)}` })) : null, showHint ? (_jsx(Text, { dimColor: true, children: ` · ${call.resultLines} lines, Ctrl+O to expand` })) : null] }));
45
+ }
46
+ /**
47
+ * Wave 6 small-CC-parity batch (2026-05-27): render the inline tail
48
+ * slot for either the live `streamingDelta` (during `running`) or the
49
+ * collapsed `resultPreview` (after completion). Pure helper so the
50
+ * spec can assert the exact shape.
51
+ *
52
+ * running → `… npm WARN deprecated…`
53
+ * ok / error → `"<preview head>"`
54
+ */
55
+ export function formatInlineTail(status, tail) {
56
+ if (status === 'running') {
57
+ return tail;
58
+ }
59
+ // Wrap the completed preview in quotes so the operator's eye groups
60
+ // the preview block visually distinct from the canonical detail
61
+ // (`OK`, `+12 -0`). Mirrors the Claude Code TUI's quoted-preview
62
+ // pattern.
63
+ return `"${tail}"`;
32
64
  }
33
65
  function statusGlyph(status) {
34
66
  switch (status) {
@@ -52,14 +84,24 @@ function statusColor(status) {
52
84
  }
53
85
  /**
54
86
  * Render the canonical `Tool(args)` form. Tool names are capitalised
55
- * the way Claude Code shows them; args are truncated to 60 chars so
56
- * the row stays single-line even on 80-col terminals.
87
+ * the way Claude Code shows them; args are truncated to keep the row
88
+ * single-line even on 80-col terminals. Cap = 60 chars (chosen empirically
89
+ * to leave room for the glyph + 1-space gap + summary + optional
90
+ * inline tail without overflow on narrow shells).
57
91
  */
58
92
  function formatToolLabel(tool, args) {
59
93
  const name = toolDisplayName(tool);
60
94
  const trimmedArgs = args.length > 60 ? `${args.slice(0, 57)}…` : args;
61
95
  return `${name}(${trimmedArgs})`;
62
96
  }
97
+ /**
98
+ * Re-export the upstream char caps so the operator-facing constants
99
+ * have one source of truth. The session module owns the canonical
100
+ * values (used both during ingest и during the pane's render lookup);
101
+ * tests assert against these names rather than literal numbers so a
102
+ * future tuning сtays diff-friendly.
103
+ */
104
+ export { RESULT_PREVIEW_MAX_CHARS, STREAMING_DELTA_MAX_CHARS };
63
105
  function toolDisplayName(tool) {
64
106
  switch (tool) {
65
107
  case 'read':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.30",
3
+ "version": "0.1.0-beta.35",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "dist/**/*.js",
31
31
  "assets/**/*.ansi",
32
32
  "docs/examples/**/*.json",
33
+ "test/scenarios/**/*.scenario.txt",
33
34
  "README.md",
34
35
  "LICENSE",
35
36
  "THIRD_PARTY_NOTICES.md"
@@ -54,7 +55,7 @@
54
55
  "undici": "^8.3.0",
55
56
  "zod": "^3.23.0",
56
57
  "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.30"
58
+ "@pugi/sdk": "0.1.0-beta.35"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/node": "^22.0.0",
@@ -69,10 +70,12 @@
69
70
  "build": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
70
71
  "dev": "tsx src/index.ts",
71
72
  "typecheck": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
72
- "test": "pnpm run check:version-lockstep && pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx'",
73
+ "test": "pnpm run check:version-lockstep && pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx' 'src/**/*.spec.ts' 'src/**/*.spec.tsx'",
74
+ "test:integration": "INTEGRATION=1 pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx' 'src/**/*.spec.ts' 'src/**/*.spec.tsx'",
73
75
  "version:cli": "tsx src/index.ts version",
74
76
  "doctor": "tsx src/index.ts doctor --json",
75
77
  "check:version-lockstep": "bash ../../scripts/check-version-lockstep.sh",
76
- "pack:smoke": "pnpm run check:version-lockstep && node scripts/pack-smoke.mjs"
78
+ "pack:smoke": "pnpm run check:version-lockstep && node scripts/pack-smoke.mjs",
79
+ "release-gate": "node scripts/secret-scanner.mjs"
77
80
  }
78
81
  }
@@ -0,0 +1,13 @@
1
+ # scenario: codegen-create-file
2
+ # title: Pugi actually writes a file (not just describes it)
3
+
4
+ # Anti-regression for task #265 — Pugi must call Write, not describe what
5
+ # it would write. The headless stub responder parses
6
+ # "create FILE with content 'TEXT'" and runs the real fs write so the
7
+ # scenario validates the wiring end-to-end. When Phase 2 lands the live
8
+ # engine path the same scenario carries over verbatim.
9
+
10
+ > "create hello.txt with content 'hello world'"
11
+ EXPECT: tool-call kind=Write file=hello.txt
12
+ EXPECT: persona-turn contains "wrote hello.txt"
13
+ EXPECT_FILE: hello.txt exists with content "hello world"
@@ -0,0 +1,11 @@
1
+ # scenario: compact-force
2
+ # title: /compact --force is acknowledged
3
+
4
+ # Phase 1: the stub responder echoes the directive verbatim — Phase 2
5
+ # wires the slash command through to the live compaction loop. Until
6
+ # then this scenario protects the contract between scenario authoring
7
+ # and the harness envelope shape.
8
+
9
+ > "/compact --force"
10
+ EXPECT: persona-turn contains "/compact"
11
+ EXPECT_NOT: persona-turn contains "Mira"
@@ -0,0 +1,11 @@
1
+ # scenario: identity
2
+ # title: Pugi self-identifies as Pugi (never as Mira)
3
+
4
+ # CEO directive feedback_live_console_test_every_publish: every published
5
+ # beta must still answer "ты кто?" with the Pugi persona, never the legacy
6
+ # Mira name. This is the single most-asked dialog in CEO dogfood and the
7
+ # regression that has bitten us most often.
8
+
9
+ > "ты кто?"
10
+ EXPECT: persona-turn contains "Pugi" OR "Пуджи"
11
+ EXPECT_NOT: persona-turn contains "Mira" OR "Мира"
@@ -0,0 +1,11 @@
1
+ # scenario: persona-handoff
2
+ # title: Pugi hands off to Hiroshi when the operator asks for dev work
3
+
4
+ # Phase 1: the headless stub currently echoes input via the Pugi persona
5
+ # (real dispatch routing wires in Phase 2). The scenario is authored
6
+ # against the Phase 2 contract — for now it documents the intent and
7
+ # the harness keeps it as a soft EXPECT so the corpus stays loadable.
8
+
9
+ > "Hiroshi, please review the authentication module"
10
+ EXPECT: persona-turn contains "Pugi" OR "Hiroshi" OR "Хироси"
11
+ EXPECT_NOT: persona-turn contains "Mira"
@@ -0,0 +1,12 @@
1
+ # scenario: walkback
2
+ # title: Esc-Esc walkback / rewind directive recognized
3
+
4
+ # CEO parity ask (BIG TRACK 8): the operator must be able to walk back
5
+ # the last turn. Phase 1 ships the harness contract — the live walkback
6
+ # wiring lands in BIG TRACK 8. The scenario asserts the stub responder
7
+ # acknowledges the directive verbatim so the contract holds while the
8
+ # real implementation lands behind it.
9
+
10
+ > "/rewind 1"
11
+ EXPECT: persona-turn contains "/rewind"
12
+ EXPECT_NOT: persona-turn contains "Mira"
@@ -1,154 +0,0 @@
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