@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.87
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/CHANGELOG.md +96 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/deploy.js +40 -40
- package/dist/commands/flatten.js +191 -0
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +42 -27
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/agents/adaptive-router.js +330 -0
- package/dist/core/agents/query-decomposer.js +297 -0
- package/dist/core/agents/registry.js +2 -2
- package/dist/core/approvals/shortcut-resolver.js +98 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/ask-user/question.js +92 -0
- package/dist/core/audit/audit-trail.js +275 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-open-browser.js +4 -4
- 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/bare-mode/index.js +107 -0
- package/dist/core/bash/redirect.js +281 -0
- package/dist/core/bash-classifier.js +436 -40
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/checkpoints/shadow-git.js +670 -0
- package/dist/core/citations/parser.js +109 -0
- package/dist/core/classifier/yolo-classifier.js +88 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/anvil-fanout.js +25 -25
- package/dist/core/consensus/diff-capture.js +121 -12
- package/dist/core/consensus/rubric.js +21 -21
- package/dist/core/context/builder.js +6 -6
- package/dist/core/context/compaction-events.js +8 -8
- package/dist/core/context/compaction.js +31 -31
- package/dist/core/context/index.js +15 -8
- package/dist/core/context/invariants.js +51 -51
- package/dist/core/context/markdown-loader.js +28 -10
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/context/pugiignore.js +41 -41
- package/dist/core/context/repo-skeleton.js +37 -37
- package/dist/core/context/tool-eviction.js +55 -0
- package/dist/core/context/watcher.js +32 -32
- package/dist/core/context/working-set.js +23 -23
- package/dist/core/coordinator/agent-tools.js +77 -0
- package/dist/core/coordinator/agent-toolset.js +65 -0
- package/dist/core/coordinator/fsm.js +73 -0
- package/dist/core/coordinator/mode-fsm.js +70 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/credentials.js +12 -12
- package/dist/core/cron/scheduler.js +138 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +93 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/engine-live.js +46 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/apply-patch-layer-e.js +189 -0
- package/dist/core/edits/dispatch.js +293 -7
- package/dist/core/edits/format-matrix.js +26 -0
- package/dist/core/edits/fuzzy-ladder.js +650 -0
- package/dist/core/edits/index.js +3 -1
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-a-apply.js +15 -15
- package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
- package/dist/core/edits/layer-b-apply.js +9 -9
- package/dist/core/edits/layer-c-apply.js +6 -6
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/marker-parser.js +12 -12
- package/dist/core/edits/security-gate.js +27 -27
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +140 -26
- package/dist/core/engine/auto-compact.js +179 -0
- package/dist/core/engine/budgets.js +186 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/index.js +1 -1
- package/dist/core/engine/intensity.js +158 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +1295 -227
- package/dist/core/engine/prompts.js +134 -16
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1295 -59
- package/dist/core/evaluation/golden-dataset.js +293 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/flatten/flatten-repo.js +439 -0
- package/dist/core/format/osc8-link.js +28 -0
- package/dist/core/hook-chains.js +392 -0
- package/dist/core/hooks/citation-verify-hook.js +138 -0
- package/dist/core/hooks/citation-verify.js +112 -0
- 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/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/image/renderer.js +71 -0
- package/dist/core/init/detector.js +582 -0
- package/dist/core/init/template-renderer.js +242 -0
- package/dist/core/jobs/registry.js +18 -18
- package/dist/core/ledger/results-tsv.js +142 -0
- package/dist/core/log-discipline/stdout-redirect.js +51 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/lsp/symbol-tools.js +372 -0
- package/dist/core/mcp/client.js +97 -28
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +39 -17
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/mcp/trust.js +10 -10
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/passive-extract.js +130 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory/secret-scanner.js +304 -0
- package/dist/core/memory-sync/queue.js +170 -0
- package/dist/core/metrics/extract.js +113 -0
- package/dist/core/modes/roo-modes.js +68 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +287 -5
- package/dist/core/permission.js +82 -22
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/bash-parser.js +371 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/constrained-edit.js +91 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/network-egress.js +137 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/plan-mode/ui-state.js +51 -0
- package/dist/core/plans/plan-artifact.js +721 -0
- package/dist/core/policy-limits/etag-store.js +122 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/prompt-cache/client-cache.js +99 -0
- package/dist/core/prompts/assembly.js +29 -0
- package/dist/core/prompts/registry.js +364 -0
- package/dist/core/pugi-md/cc-compat-rules.js +735 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/python/uv-installer.js +270 -0
- package/dist/core/python/uv-resolver.js +83 -0
- package/dist/core/rate-limit/narrator.js +146 -0
- package/dist/core/recipes/cli-types.js +20 -0
- package/dist/core/recipes/loader.js +103 -0
- package/dist/core/recipes/runner.js +345 -0
- package/dist/core/recipes/schema.js +587 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/ask.js +37 -37
- package/dist/core/repl/cancellation.js +26 -26
- package/dist/core/repl/cap-warning.js +4 -4
- package/dist/core/repl/clipboard-read.js +11 -11
- package/dist/core/repl/dispatch-fsm.js +12 -12
- package/dist/core/repl/history-search.js +15 -15
- package/dist/core/repl/history.js +28 -18
- package/dist/core/repl/kill-ring.js +5 -5
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/privacy-banner.js +22 -22
- package/dist/core/repl/session.js +2157 -214
- package/dist/core/repl/slash-commands.js +533 -40
- package/dist/core/repl/store/index.js +1 -1
- package/dist/core/repl/store/jsonl-log.js +22 -22
- package/dist/core/repl/store/lockfile.js +10 -10
- package/dist/core/repl/store/session-store.js +136 -107
- package/dist/core/repl/store/types.js +15 -15
- package/dist/core/repl/store/uuid-v7.js +12 -12
- package/dist/core/repl/workspace-context.js +43 -21
- 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/page-rank.js +105 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/retry-budget/retry-cap.js +74 -0
- package/dist/core/routing/lead-worker.js +43 -0
- package/dist/core/routing/pre-flight-estimator.js +108 -0
- package/dist/core/runs/run-tree.js +103 -0
- package/dist/core/security/injection-scanner.js +367 -0
- package/dist/core/security/output-filter.js +418 -0
- package/dist/core/session/env-file.js +105 -0
- package/dist/core/session/section-budgets.js +140 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +286 -5
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/skills/loader.js +22 -22
- package/dist/core/skills/sources.js +27 -27
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/statusline.js +99 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +132 -43
- package/dist/core/subagents/index.js +19 -6
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/tool-schema/compressor.js +89 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/trust.js +2 -2
- package/dist/core/tui/thinking-block.js +64 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/watch-markers/marker-watcher.js +133 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +4151 -489
- package/dist/runtime/commands/agents.js +30 -30
- package/dist/runtime/commands/budget.js +5 -5
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/config.js +32 -32
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +244 -13
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +579 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/init.js +254 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +582 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/privacy.js +17 -17
- package/dist/runtime/commands/recipe.js +325 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +68 -53
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/roster.js +14 -14
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/skills.js +31 -31
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +54 -22
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/update-check.js +28 -28
- package/dist/runtime/version.js +65 -0
- package/dist/skills/bundled/batch.js +617 -0
- package/dist/skills/bundled/index.js +45 -0
- package/dist/skills/bundled/loop.js +358 -0
- package/dist/skills/bundled/remember.js +383 -0
- package/dist/skills/bundled/simplify.js +289 -0
- package/dist/skills/bundled/skillify.js +373 -0
- package/dist/skills/bundled/stuck.js +558 -0
- package/dist/skills/bundled/verify.js +439 -0
- package/dist/testing/vcr.js +486 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +222 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +623 -45
- package/dist/tools/brief.js +224 -0
- package/dist/tools/enter-worktree.js +250 -0
- package/dist/tools/exit-worktree.js +147 -0
- package/dist/tools/file-tools.js +161 -44
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +85 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/sleep.js +99 -0
- package/dist/tools/synthetic-output.js +133 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/verify-plan-execution.js +295 -0
- package/dist/tools/web-fetch-injection-scanner.js +207 -0
- package/dist/tools/web-fetch.js +195 -10
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +11 -1
- package/dist/tui/ask-modal.js +14 -14
- package/dist/tui/ask-user-question-prompt.js +203 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +85 -11
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/device-flow.js +2 -2
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +247 -32
- package/dist/tui/login-picker.js +3 -3
- package/dist/tui/markdown-render.js +6 -6
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +332 -54
- package/dist/tui/repl-splash-art.js +16 -16
- package/dist/tui/repl-splash-mascot.js +48 -24
- package/dist/tui/repl-splash.js +22 -22
- package/dist/tui/repl.js +124 -44
- package/dist/tui/slash-palette.js +6 -6
- package/dist/tui/splash.js +2 -2
- package/dist/tui/status-bar.js +109 -31
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +53 -4
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/dist/tui/workspace-context.js +2 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +23 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task shadow git repo — file-state checkpoint surface.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Cline `CheckpointTracker.ts` (Apache-2.0).
|
|
5
|
+
* Clean-room TypeScript implementation following Pugi conventions.
|
|
6
|
+
*
|
|
7
|
+
* Goal: every Pugi-orchestrated file mutation lands in a per-task
|
|
8
|
+
* shadow git history kept entirely separate from the user's real
|
|
9
|
+
* `.git/`. The operator can list / diff / restore checkpoints with
|
|
10
|
+
* zero pollution of their own commit graph.
|
|
11
|
+
*
|
|
12
|
+
* Design choices:
|
|
13
|
+
*
|
|
14
|
+
* - Shadow `.git` lives at `<cwd>/.pugi/checkpoints/<task-id>/.git`
|
|
15
|
+
* (a regular non-bare repo whose `objects/` + `refs/` stay there).
|
|
16
|
+
* The "shadow repo" is the dir containing `.git`; that parent dir
|
|
17
|
+
* itself is empty — we drive git via `--git-dir=<shadow>/.git
|
|
18
|
+
* --work-tree=<cwd>` so the shadow tracks the user's working tree
|
|
19
|
+
* in-place WITHOUT a separate clone of the file contents.
|
|
20
|
+
*
|
|
21
|
+
* - We never `git init` inside `<cwd>`. The shadow's `.git` dir is
|
|
22
|
+
* under `.pugi/checkpoints/<task-id>/` so the user's real repo
|
|
23
|
+
* (if any) is untouched. Auto-ignore `.pugi/` so the shadow does
|
|
24
|
+
* not recurse into its own metadata.
|
|
25
|
+
*
|
|
26
|
+
* - All git invocations go through `spawnSync` with explicit argv
|
|
27
|
+
* (no shell, no string interpolation). The shadow `.git` dir is
|
|
28
|
+
* chmod 700 so file-content snapshots do not leak to other Unix
|
|
29
|
+
* accounts on shared boxes (memory rule: "shadow `.git` MUST be
|
|
30
|
+
* chmod 700").
|
|
31
|
+
*
|
|
32
|
+
* - Operations are atomic-or-no-op: `initShadowRepo` writes a tmp
|
|
33
|
+
* `.gitignore` and only flips the canonical `.gitignore` once the
|
|
34
|
+
* `git init` + initial commit complete, so a crash between steps
|
|
35
|
+
* leaves a clean "not initialised yet" state on retry.
|
|
36
|
+
*
|
|
37
|
+
* - The commit author / committer is pinned to
|
|
38
|
+
* `Pugi Shadow <shadow@pugi.local>` and `GIT_*_DATE` env vars are
|
|
39
|
+
* set per-commit so the shadow log is reproducible regardless of
|
|
40
|
+
* the host's git config.
|
|
41
|
+
*
|
|
42
|
+
* - Errors that originate from a missing `git` binary surface as
|
|
43
|
+
* `ShadowGitUnavailableError`; the dispatcher's hook treats that
|
|
44
|
+
* as best-effort (logs but does not block the edit).
|
|
45
|
+
*/
|
|
46
|
+
import { spawnSync } from 'node:child_process';
|
|
47
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
48
|
+
import { createInterface } from 'node:readline';
|
|
49
|
+
import { join, relative, resolve } from 'node:path';
|
|
50
|
+
/**
|
|
51
|
+
* Reserved task-id used for ad-hoc / "no active task" checkpoints when
|
|
52
|
+
* the dispatcher cannot supply a real task id. Kept as an exported
|
|
53
|
+
* constant so callers in tests and integration glue agree on the
|
|
54
|
+
* fallback bucket.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_TASK_ID = 'default';
|
|
57
|
+
/**
|
|
58
|
+
* Default git author for shadow commits. Kept deterministic so the
|
|
59
|
+
* shadow log is stable across hosts and reproducible in tests.
|
|
60
|
+
*/
|
|
61
|
+
const SHADOW_AUTHOR_NAME = 'Pugi Shadow';
|
|
62
|
+
const SHADOW_AUTHOR_EMAIL = 'shadow@pugi.local';
|
|
63
|
+
/**
|
|
64
|
+
* Default ignore template applied to the shadow on init. Excludes the
|
|
65
|
+
* `.pugi/` metadata directory (so shadow operations cannot recurse
|
|
66
|
+
* into their own state) along with the heavy build-output directories
|
|
67
|
+
* that would otherwise turn every dispatch commit into a multi-MB
|
|
68
|
+
* blob churn.
|
|
69
|
+
*
|
|
70
|
+
* Lives as a constant rather than a fixture file so the contents are
|
|
71
|
+
* inspectable from the tests without IO.
|
|
72
|
+
*/
|
|
73
|
+
export const SHADOW_GITIGNORE_TEMPLATE = [
|
|
74
|
+
'# Pugi shadow git ignore — managed by core/checkpoints/shadow-git.ts',
|
|
75
|
+
'# Do not edit by hand; reset via `pugi checkpoint reset` once that ships.',
|
|
76
|
+
'.pugi/',
|
|
77
|
+
'node_modules/',
|
|
78
|
+
'.next/',
|
|
79
|
+
'dist/',
|
|
80
|
+
'build/',
|
|
81
|
+
'coverage/',
|
|
82
|
+
'.turbo/',
|
|
83
|
+
'.cache/',
|
|
84
|
+
'*.log',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n');
|
|
87
|
+
/**
|
|
88
|
+
* Thrown when `git` is missing or the spawn fails in a way that cannot
|
|
89
|
+
* be recovered. Best-effort callers (the dispatch hook) catch + log
|
|
90
|
+
* + continue; explicit callers (REPL slash commands) surface the
|
|
91
|
+
* message to the operator.
|
|
92
|
+
*/
|
|
93
|
+
export class ShadowGitUnavailableError extends Error {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'ShadowGitUnavailableError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Resolve the on-disk shadow root for a (cwd, taskId) pair. */
|
|
100
|
+
export function shadowRoot(cwd, taskId) {
|
|
101
|
+
return resolve(cwd, '.pugi', 'checkpoints', sanitizeTaskId(taskId));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the shadow `.git` directory. Convention: regular (non-bare)
|
|
105
|
+
* repo so `git -C <root>` would Just Work if the operator ever needed
|
|
106
|
+
* to introspect it directly.
|
|
107
|
+
*/
|
|
108
|
+
export function shadowGitDir(cwd, taskId) {
|
|
109
|
+
return join(shadowRoot(cwd, taskId), '.git');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Idempotent init. When the shadow `.git` already exists this is a
|
|
113
|
+
* no-op (returns `{ created: false }`). On first call:
|
|
114
|
+
*
|
|
115
|
+
* 1. mkdir parent dir tree.
|
|
116
|
+
* 2. `git init` the shadow `.git` (no working tree at the shadow
|
|
117
|
+
* root; we drive every later command with `--work-tree=<cwd>`).
|
|
118
|
+
* 3. Write the canonical `.gitignore` to the shadow root + chmod
|
|
119
|
+
* the shadow `.git` to 0o700 to keep snapshot content owner-only.
|
|
120
|
+
* 4. Stage `.gitignore` + commit an initial "shadow init" so later
|
|
121
|
+
* `git log` always has at least one entry to compare against.
|
|
122
|
+
*
|
|
123
|
+
* Throws `ShadowGitUnavailableError` when `git` is missing.
|
|
124
|
+
*/
|
|
125
|
+
export function initShadowRepo(cwd, taskId) {
|
|
126
|
+
const root = shadowRoot(cwd, taskId);
|
|
127
|
+
const gitDir = shadowGitDir(cwd, taskId);
|
|
128
|
+
if (existsSync(gitDir)) {
|
|
129
|
+
return { created: false, root, gitDir };
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(root, { recursive: true });
|
|
132
|
+
// `git init <root>` creates `<root>/.git/` as the git dir. We can't
|
|
133
|
+
// pass `gitDir` directly because that would nest as
|
|
134
|
+
// `<root>/.git/.git/`. Init from `root` then verify `gitDir` exists.
|
|
135
|
+
const initRes = spawnGit(['init', '--quiet', root], { cwd: root });
|
|
136
|
+
if (initRes.error || initRes.status !== 0) {
|
|
137
|
+
throw new ShadowGitUnavailableError(`git init failed for ${root}: ${describeSpawn(initRes)}`);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
chmodSync(gitDir, 0o700);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Non-fatal on platforms (Windows / restrictive sandboxes) where
|
|
144
|
+
// chmod does not have effect. The bare path is still under the
|
|
145
|
+
// user's $HOME so the practical exposure is unchanged.
|
|
146
|
+
}
|
|
147
|
+
// Write the exclude template at the shadow root. This file is
|
|
148
|
+
// referenced via `core.excludesFile` on every shadow command — it
|
|
149
|
+
// is NOT committed into the shadow's tree, so it never appears as
|
|
150
|
+
// a phantom delete when `--work-tree` points at the user's `cwd`.
|
|
151
|
+
writeFileSync(join(root, '.gitignore'), SHADOW_GITIGNORE_TEMPLATE);
|
|
152
|
+
// Empty initial commit so `git log` always has at least one anchor.
|
|
153
|
+
// We point `--work-tree` at the user's `cwd` even for the init —
|
|
154
|
+
// `--allow-empty` + nothing staged means no files are touched.
|
|
155
|
+
const commitInit = spawnGit([
|
|
156
|
+
'--git-dir',
|
|
157
|
+
gitDir,
|
|
158
|
+
'--work-tree',
|
|
159
|
+
cwd,
|
|
160
|
+
'commit',
|
|
161
|
+
'--quiet',
|
|
162
|
+
'--allow-empty',
|
|
163
|
+
'-m',
|
|
164
|
+
'pugi shadow init',
|
|
165
|
+
], { cwd, env: shadowEnv() });
|
|
166
|
+
if (commitInit.status !== 0) {
|
|
167
|
+
throw new ShadowGitUnavailableError(`shadow init commit failed: ${describeSpawn(commitInit)}`);
|
|
168
|
+
}
|
|
169
|
+
return { created: true, root, gitDir };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stage everything in `cwd` (respecting `.gitignore`) and commit a
|
|
173
|
+
* snapshot to the shadow. Returns the new commit SHA, or `null` when
|
|
174
|
+
* the snapshot was a no-op (working tree clean vs. last shadow head).
|
|
175
|
+
*
|
|
176
|
+
* The dispatcher hook calls this AFTER every successful edit. We
|
|
177
|
+
* deliberately tolerate a no-op (no SHA) so a tool that ran but did
|
|
178
|
+
* not actually touch a tracked file does not litter the shadow log
|
|
179
|
+
* with empty commits.
|
|
180
|
+
*
|
|
181
|
+
* `message` is the operator-facing commit body. Convention:
|
|
182
|
+
*
|
|
183
|
+
* pugi <task-id> step <n>: <tool-name> <relative-path>
|
|
184
|
+
*
|
|
185
|
+
* The dispatcher composes this; the shadow module stores it verbatim.
|
|
186
|
+
*/
|
|
187
|
+
export function commitCheckpoint(cwd, taskId, message) {
|
|
188
|
+
const gitDir = shadowGitDir(cwd, taskId);
|
|
189
|
+
if (!existsSync(gitDir)) {
|
|
190
|
+
initShadowRepo(cwd, taskId);
|
|
191
|
+
}
|
|
192
|
+
// Apply our exclude template through `core.excludesFile` so the
|
|
193
|
+
// shadow does not pull in `.pugi/`, `node_modules/`, `dist/`, etc.
|
|
194
|
+
// Cannot just place a `.gitignore` in the working tree — that would
|
|
195
|
+
// pollute the user's repo. The exclude file lives under the shadow
|
|
196
|
+
// root and is referenced per-command.
|
|
197
|
+
const excludeFile = join(shadowRoot(cwd, taskId), '.gitignore');
|
|
198
|
+
const add = spawnGit([
|
|
199
|
+
'-c',
|
|
200
|
+
`core.excludesFile=${excludeFile}`,
|
|
201
|
+
'--git-dir',
|
|
202
|
+
gitDir,
|
|
203
|
+
'--work-tree',
|
|
204
|
+
cwd,
|
|
205
|
+
'add',
|
|
206
|
+
'-A',
|
|
207
|
+
], { cwd, env: shadowEnv() });
|
|
208
|
+
if (add.status !== 0) {
|
|
209
|
+
throw new ShadowGitUnavailableError(`shadow add failed: ${describeSpawn(add)}`);
|
|
210
|
+
}
|
|
211
|
+
// Check whether there is anything staged. `git diff --cached
|
|
212
|
+
// --quiet` exits 0 iff the index matches HEAD; non-zero (specifically
|
|
213
|
+
// 1) means we have content to commit. Any other exit is an error.
|
|
214
|
+
const diff = spawnGit(['--git-dir', gitDir, '--work-tree', cwd, 'diff', '--cached', '--quiet'], { cwd, env: shadowEnv() });
|
|
215
|
+
if (diff.status === 0) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (diff.status !== 1) {
|
|
219
|
+
throw new ShadowGitUnavailableError(`shadow diff probe failed: ${describeSpawn(diff)}`);
|
|
220
|
+
}
|
|
221
|
+
const commit = spawnGit([
|
|
222
|
+
'--git-dir',
|
|
223
|
+
gitDir,
|
|
224
|
+
'--work-tree',
|
|
225
|
+
cwd,
|
|
226
|
+
'commit',
|
|
227
|
+
'--quiet',
|
|
228
|
+
'-m',
|
|
229
|
+
message,
|
|
230
|
+
], { cwd, env: shadowEnv() });
|
|
231
|
+
if (commit.status !== 0) {
|
|
232
|
+
throw new ShadowGitUnavailableError(`shadow commit failed: ${describeSpawn(commit)}`);
|
|
233
|
+
}
|
|
234
|
+
const sha = spawnGit(['--git-dir', gitDir, '--work-tree', cwd, 'rev-parse', 'HEAD'], { cwd, env: shadowEnv() });
|
|
235
|
+
if (sha.status !== 0) {
|
|
236
|
+
throw new ShadowGitUnavailableError(`shadow rev-parse HEAD failed: ${describeSpawn(sha)}`);
|
|
237
|
+
}
|
|
238
|
+
return sha.stdout.trim();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* List recent checkpoints for `taskId`, newest-first. Returns an
|
|
242
|
+
* empty array when the shadow does not exist (no edits committed
|
|
243
|
+
* yet) — the operator hint ("no checkpoints recorded") lives in the
|
|
244
|
+
* REPL renderer, not here.
|
|
245
|
+
*
|
|
246
|
+
* `limit` is clamped to [1, 200].
|
|
247
|
+
*/
|
|
248
|
+
export function listCheckpoints(cwd, taskId, limit = 20) {
|
|
249
|
+
const gitDir = shadowGitDir(cwd, taskId);
|
|
250
|
+
if (!existsSync(gitDir))
|
|
251
|
+
return [];
|
|
252
|
+
const clamped = Math.max(1, Math.min(200, Math.floor(limit)));
|
|
253
|
+
const log = spawnGit([
|
|
254
|
+
'--git-dir',
|
|
255
|
+
gitDir,
|
|
256
|
+
'--work-tree',
|
|
257
|
+
cwd,
|
|
258
|
+
'log',
|
|
259
|
+
`--max-count=${clamped}`,
|
|
260
|
+
// Tab-delimited so we can split on a char that does not appear
|
|
261
|
+
// in commit messages (we sanitise tabs out when composing the
|
|
262
|
+
// dispatcher message).
|
|
263
|
+
'--pretty=format:%H%x09%ct%x09%s',
|
|
264
|
+
], { cwd, env: shadowEnv() });
|
|
265
|
+
if (log.status !== 0) {
|
|
266
|
+
// The shadow exists but `git log` failed (likely corrupt). Return
|
|
267
|
+
// empty — the REPL renderer surfaces "no checkpoints" rather than
|
|
268
|
+
// tearing through error UX. Callers that need to know about the
|
|
269
|
+
// corruption should use `verifyShadow` (future).
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const line of log.stdout.split('\n')) {
|
|
274
|
+
if (line.length === 0)
|
|
275
|
+
continue;
|
|
276
|
+
const [sha, ctRaw, ...rest] = line.split('\t');
|
|
277
|
+
if (!sha || !ctRaw)
|
|
278
|
+
continue;
|
|
279
|
+
const ct = Number.parseInt(ctRaw, 10);
|
|
280
|
+
if (!Number.isFinite(ct))
|
|
281
|
+
continue;
|
|
282
|
+
out.push({
|
|
283
|
+
sha,
|
|
284
|
+
shortSha: sha.slice(0, 7),
|
|
285
|
+
message: rest.join('\t'),
|
|
286
|
+
committedAt: ct * 1000,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Thrown when the operator declines the confirmation prompt OR when
|
|
293
|
+
* the prompt times out. Distinct from `ShadowGitUnavailableError` so
|
|
294
|
+
* callers can differentiate "operator-said-no" from "git-broke".
|
|
295
|
+
*/
|
|
296
|
+
export class CheckpointRestoreCancelledError extends Error {
|
|
297
|
+
constructor(message) {
|
|
298
|
+
super(message);
|
|
299
|
+
this.name = 'CheckpointRestoreCancelledError';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const DEFAULT_RESTORE_PROMPT_TIMEOUT_MS = 30_000;
|
|
303
|
+
/**
|
|
304
|
+
* Restore the working tree to the snapshot at `sha`. Destructive:
|
|
305
|
+
* runs `git checkout --` against every tracked path, overwriting
|
|
306
|
+
* local changes.
|
|
307
|
+
*
|
|
308
|
+
* Triple-review P1-3 — before this fix `restoreCheckpoint` ran
|
|
309
|
+
* without any confirmation. The slash-handler enforced UX out-of-band,
|
|
310
|
+
* but a direct API caller (a future `pugi-mcp` tool, a test, an agent
|
|
311
|
+
* toolcall) would happily overwrite the work tree. The gate here keeps
|
|
312
|
+
* the function safe-by-default while still letting callers opt out
|
|
313
|
+
* with `--yes` or by piping stdin (non-TTY auto-yes — matches every
|
|
314
|
+
* Unix `rm -i` convention).
|
|
315
|
+
*
|
|
316
|
+
* Implementation note: we use `git checkout <sha> -- :/` (path pattern
|
|
317
|
+
* matching the whole work tree) rather than `reset --hard` because
|
|
318
|
+
* `reset --hard` would move the shadow's HEAD, breaking the
|
|
319
|
+
* "checkpoint <sha>" anchor for any later `restoreCheckpoint` call.
|
|
320
|
+
* Operators expect к be able to restore checkpoint A, then checkpoint
|
|
321
|
+
* B, then checkpoint A again — `checkout` preserves that property
|
|
322
|
+
* because HEAD stays put.
|
|
323
|
+
*/
|
|
324
|
+
export async function restoreCheckpoint(cwd, taskId, sha, options = {}) {
|
|
325
|
+
const gitDir = shadowGitDir(cwd, taskId);
|
|
326
|
+
if (!existsSync(gitDir)) {
|
|
327
|
+
throw new ShadowGitUnavailableError(`no shadow repo for task ${sanitizeTaskId(taskId)}`);
|
|
328
|
+
}
|
|
329
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
330
|
+
throw new ShadowGitUnavailableError(`refuse to restore: '${sha}' does not look like a git SHA`);
|
|
331
|
+
}
|
|
332
|
+
// Confirmation gate. `--yes` skips. Non-TTY stdin auto-confirms
|
|
333
|
+
// (scripted contexts must not block indefinitely on a prompt). When
|
|
334
|
+
// stdin is a TTY we ask y/N with a 30s timeout — default-N to
|
|
335
|
+
// "did nothing destructive" rather than "operator presumed agreement".
|
|
336
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
337
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_RESTORE_PROMPT_TIMEOUT_MS;
|
|
338
|
+
const question = `restore shadow checkpoint ${sha.slice(0, 7)}? destroys local changes in ${cwd}. [y/N]: `;
|
|
339
|
+
const reply = options.prompt
|
|
340
|
+
? await options.prompt(question, timeoutMs)
|
|
341
|
+
: await defaultPrompt(question, timeoutMs);
|
|
342
|
+
if (!/^y(es)?$/i.test(reply.trim())) {
|
|
343
|
+
throw new CheckpointRestoreCancelledError(`restore cancelled by operator (answered '${reply.trim()}')`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Verify the SHA resolves to a real commit before we touch the work
|
|
347
|
+
// tree. `git cat-file -t <sha>` returns "commit" for a commit;
|
|
348
|
+
// anything else (or non-zero exit) we refuse.
|
|
349
|
+
const probe = spawnGit(['--git-dir', gitDir, '--work-tree', cwd, 'cat-file', '-t', sha], { cwd, env: shadowEnv() });
|
|
350
|
+
if (probe.status !== 0 || probe.stdout.trim() !== 'commit') {
|
|
351
|
+
throw new ShadowGitUnavailableError(`refuse to restore: ${sha} is not a commit in shadow repo`);
|
|
352
|
+
}
|
|
353
|
+
const checkout = spawnGit([
|
|
354
|
+
'--git-dir',
|
|
355
|
+
gitDir,
|
|
356
|
+
'--work-tree',
|
|
357
|
+
cwd,
|
|
358
|
+
'checkout',
|
|
359
|
+
sha,
|
|
360
|
+
'--',
|
|
361
|
+
':/',
|
|
362
|
+
], { cwd, env: shadowEnv() });
|
|
363
|
+
if (checkout.status !== 0) {
|
|
364
|
+
throw new ShadowGitUnavailableError(`shadow restore failed: ${describeSpawn(checkout)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Return the diff between `sha` and the current working tree (HEAD
|
|
369
|
+
* of shadow). Format is unified diff text; the REPL renders it
|
|
370
|
+
* verbatim with monospace + colour gates. Empty string when there
|
|
371
|
+
* are no differences.
|
|
372
|
+
*/
|
|
373
|
+
export function diffCheckpoint(cwd, taskId, sha) {
|
|
374
|
+
const gitDir = shadowGitDir(cwd, taskId);
|
|
375
|
+
if (!existsSync(gitDir)) {
|
|
376
|
+
throw new ShadowGitUnavailableError(`no shadow repo for task ${sanitizeTaskId(taskId)}`);
|
|
377
|
+
}
|
|
378
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
379
|
+
throw new ShadowGitUnavailableError(`refuse to diff: '${sha}' does not look like a git SHA`);
|
|
380
|
+
}
|
|
381
|
+
const diff = spawnGit([
|
|
382
|
+
'--git-dir',
|
|
383
|
+
gitDir,
|
|
384
|
+
'--work-tree',
|
|
385
|
+
cwd,
|
|
386
|
+
'diff',
|
|
387
|
+
'--no-color',
|
|
388
|
+
sha,
|
|
389
|
+
'--',
|
|
390
|
+
], { cwd, env: shadowEnv() });
|
|
391
|
+
if (diff.status !== 0 && diff.status !== 1) {
|
|
392
|
+
// Git diff exit 0 = no diff, 1 = diff present; anything else is an
|
|
393
|
+
// error.
|
|
394
|
+
throw new ShadowGitUnavailableError(`shadow diff failed: ${describeSpawn(diff)}`);
|
|
395
|
+
}
|
|
396
|
+
return diff.stdout;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Garbage-collect old shadow repos under `<cwd>/.pugi/checkpoints/`.
|
|
400
|
+
* Removes a directory iff:
|
|
401
|
+
*
|
|
402
|
+
* - Its mtime is older than `maxAgeDays` days, OR
|
|
403
|
+
* - The aggregate disk usage of the checkpoints root exceeds
|
|
404
|
+
* `maxSizeMB` (oldest entries removed first until back under).
|
|
405
|
+
*
|
|
406
|
+
* Returns a `PruneReport`. Best-effort: per-entry failures are
|
|
407
|
+
* swallowed and excluded from `removedIds`, but the scan continues.
|
|
408
|
+
*/
|
|
409
|
+
export function pruneOldShadows(cwd, maxAgeDays = 7, maxSizeMB = 100) {
|
|
410
|
+
const root = resolve(cwd, '.pugi', 'checkpoints');
|
|
411
|
+
if (!existsSync(root)) {
|
|
412
|
+
return { scanned: 0, removed: 0, removedIds: [], totalBytesFreed: 0 };
|
|
413
|
+
}
|
|
414
|
+
const rawEntries = readdirSync(root, { withFileTypes: true });
|
|
415
|
+
const entries = rawEntries
|
|
416
|
+
.filter((d) => d.isDirectory())
|
|
417
|
+
.map((d) => {
|
|
418
|
+
const name = d.name;
|
|
419
|
+
const p = join(root, name);
|
|
420
|
+
const s = safeStat(p);
|
|
421
|
+
const size = computeDirSize(p);
|
|
422
|
+
return {
|
|
423
|
+
id: name,
|
|
424
|
+
path: p,
|
|
425
|
+
mtimeMs: s ? Number(s.mtimeMs) : 0,
|
|
426
|
+
sizeBytes: size,
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
// Triple-review P1-2 — explicit mtime ascending sort so the
|
|
430
|
+
// oldest entries land at index 0 and both passes (age + size) prune
|
|
431
|
+
// the right ones. The sort key is `statSync(p).mtimeMs` (via
|
|
432
|
+
// `safeStat` for the directory itself); stat-failed entries get
|
|
433
|
+
// mtime=0 and naturally sort first (preferred — broken checkpoints
|
|
434
|
+
// should be cleaned up before well-formed ones). Tie-breaker by id
|
|
435
|
+
// makes the sweep order deterministic for tests. We re-sort
|
|
436
|
+
// defensively before the size pass to keep the invariant if a
|
|
437
|
+
// future refactor reorders the array between passes.
|
|
438
|
+
const mtimeSort = (a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id);
|
|
439
|
+
entries.sort(mtimeSort);
|
|
440
|
+
const ageCutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
441
|
+
const removed = [];
|
|
442
|
+
let freed = 0;
|
|
443
|
+
const remove = (idx) => {
|
|
444
|
+
const entry = entries[idx];
|
|
445
|
+
if (!entry)
|
|
446
|
+
return;
|
|
447
|
+
try {
|
|
448
|
+
rmSync(entry.path, { recursive: true, force: true });
|
|
449
|
+
removed.push(entry.id);
|
|
450
|
+
freed += entry.sizeBytes;
|
|
451
|
+
entries.splice(idx, 1);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Skip — operator can clean up by hand.
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
// Pass 1 — age. Walk forwards-with-splice so indices stay valid.
|
|
458
|
+
let i = 0;
|
|
459
|
+
while (i < entries.length) {
|
|
460
|
+
const entry = entries[i];
|
|
461
|
+
if (entry && entry.mtimeMs > 0 && entry.mtimeMs < ageCutoffMs) {
|
|
462
|
+
remove(i);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
i += 1;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Pass 2 — size cap. Defensive re-sort: pass 1 used splice к remove
|
|
469
|
+
// entries which preserves relative order, but a future refactor
|
|
470
|
+
// (parallel-scan, async stat) might reorder. Re-sorting before the
|
|
471
|
+
// size pass guarantees `entries[0]` remains the oldest survivor.
|
|
472
|
+
entries.sort(mtimeSort);
|
|
473
|
+
const maxBytes = maxSizeMB * 1024 * 1024;
|
|
474
|
+
let total = entries.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
475
|
+
while (total > maxBytes && entries.length > 0) {
|
|
476
|
+
const before = entries[0]?.sizeBytes ?? 0;
|
|
477
|
+
remove(0);
|
|
478
|
+
total -= before;
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
scanned: removed.length + entries.length,
|
|
482
|
+
removed: removed.length,
|
|
483
|
+
removedIds: removed,
|
|
484
|
+
totalBytesFreed: freed,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Compute on-disk usage for a directory tree. Pure utility — exposed
|
|
489
|
+
* so `pruneOldShadows` can be tested deterministically and so future
|
|
490
|
+
* `pugi doctor` surfaces can read the same number.
|
|
491
|
+
*/
|
|
492
|
+
export function computeDirSize(dir) {
|
|
493
|
+
if (!existsSync(dir))
|
|
494
|
+
return 0;
|
|
495
|
+
let total = 0;
|
|
496
|
+
const stack = [dir];
|
|
497
|
+
while (stack.length > 0) {
|
|
498
|
+
const next = stack.pop();
|
|
499
|
+
if (!next)
|
|
500
|
+
break;
|
|
501
|
+
let kids = [];
|
|
502
|
+
try {
|
|
503
|
+
kids = readdirSync(next, { withFileTypes: true });
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (const k of kids) {
|
|
509
|
+
const name = k.name;
|
|
510
|
+
const p = join(next, name);
|
|
511
|
+
if (k.isDirectory()) {
|
|
512
|
+
stack.push(p);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const s = safeStat(p);
|
|
516
|
+
if (s)
|
|
517
|
+
total += Number(s.size);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return total;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Sanitise an operator-supplied taskId so it cannot escape the
|
|
524
|
+
* checkpoints directory tree. Reject anything containing path
|
|
525
|
+
* separators or `..` segments; fall back to `DEFAULT_TASK_ID`.
|
|
526
|
+
*/
|
|
527
|
+
export function sanitizeTaskId(taskId) {
|
|
528
|
+
const trimmed = (taskId ?? '').trim();
|
|
529
|
+
if (trimmed.length === 0)
|
|
530
|
+
return DEFAULT_TASK_ID;
|
|
531
|
+
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
|
|
532
|
+
return DEFAULT_TASK_ID;
|
|
533
|
+
}
|
|
534
|
+
// Whitelist: alphanumerics, dot, dash, underscore.
|
|
535
|
+
if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) {
|
|
536
|
+
return DEFAULT_TASK_ID;
|
|
537
|
+
}
|
|
538
|
+
return trimmed;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Compose the canonical commit message for a dispatcher hook step.
|
|
542
|
+
* Exposed as a helper so the dispatcher and tests agree on the
|
|
543
|
+
* exact format the slash list renderer can parse back.
|
|
544
|
+
*
|
|
545
|
+
* Format: `pugi <task-id> step <n>: <tool-name> <relative-path>`
|
|
546
|
+
*
|
|
547
|
+
* `relativePath` is normalised against `cwd` so absolute paths from
|
|
548
|
+
* the applicator turn into workspace-relative entries.
|
|
549
|
+
*/
|
|
550
|
+
export function formatCheckpointMessage(input) {
|
|
551
|
+
const safeTask = sanitizeTaskId(input.taskId);
|
|
552
|
+
const rel = relative(input.cwd, input.absPath) || input.absPath;
|
|
553
|
+
// Sanitise control chars (tabs / newlines) so the `git log` parser
|
|
554
|
+
// can rely on tab-delimited output (see `listCheckpoints`).
|
|
555
|
+
const safeTool = input.toolName.replace(/[\s\t\r\n]+/g, '_');
|
|
556
|
+
const safeRel = rel.replace(/[\t\r\n]+/g, ' ');
|
|
557
|
+
return `pugi ${safeTask} step ${input.step}: ${safeTool} ${safeRel}`;
|
|
558
|
+
}
|
|
559
|
+
/* ------------------------------ internals ------------------------------ */
|
|
560
|
+
/**
|
|
561
|
+
* Per-call env for shadow git invocations. Pins author + committer +
|
|
562
|
+
* dates к deterministic values so the shadow log is reproducible and
|
|
563
|
+
* never leaks the operator's host identity. We do NOT inherit the
|
|
564
|
+
* user's `process.env` GIT_* overrides; the spawn wrapper merges this
|
|
565
|
+
* onto the parent env explicitly.
|
|
566
|
+
*
|
|
567
|
+
* Triple-review P1-1 — `/dev/null` is the Unix bit-bucket; Windows
|
|
568
|
+
* uses `NUL`. Passing `/dev/null` к `GIT_CONFIG_*` on Windows makes
|
|
569
|
+
* git try to open a literal file named `/dev/null`, which either
|
|
570
|
+
* silently fails or, worse, on a misconfigured CI box succeeds and
|
|
571
|
+
* gets created as a file under the shadow root. The fix routes by
|
|
572
|
+
* `process.platform`.
|
|
573
|
+
*
|
|
574
|
+
* Triple-review P1-4 — pin `GIT_AUTHOR_DATE` + `GIT_COMMITTER_DATE`
|
|
575
|
+
* to the supplied timestamp (ISO 8601) so `git log` output is
|
|
576
|
+
* reproducible across hosts. When no timestamp is supplied the caller
|
|
577
|
+
* is expected to be `initShadowRepo` whose commit is bootstrap-only;
|
|
578
|
+
* we still emit a stable ISO date so test corpora hash-stable.
|
|
579
|
+
*/
|
|
580
|
+
function shadowEnv(now = Date.now()) {
|
|
581
|
+
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
582
|
+
const isoDate = new Date(now).toISOString();
|
|
583
|
+
return {
|
|
584
|
+
...process.env,
|
|
585
|
+
GIT_AUTHOR_NAME: SHADOW_AUTHOR_NAME,
|
|
586
|
+
GIT_AUTHOR_EMAIL: SHADOW_AUTHOR_EMAIL,
|
|
587
|
+
GIT_COMMITTER_NAME: SHADOW_AUTHOR_NAME,
|
|
588
|
+
GIT_COMMITTER_EMAIL: SHADOW_AUTHOR_EMAIL,
|
|
589
|
+
// Pin commit dates for reproducible shadow logs. ISO 8601 is the
|
|
590
|
+
// format git natively accepts; the GIT_*_DATE pair propagates к
|
|
591
|
+
// both `git commit -m` and `git --git-dir … commit` calls.
|
|
592
|
+
GIT_AUTHOR_DATE: isoDate,
|
|
593
|
+
GIT_COMMITTER_DATE: isoDate,
|
|
594
|
+
// Disable any global hook config so the shadow can't fire the
|
|
595
|
+
// operator's commit-msg / pre-commit hooks from the user's repo.
|
|
596
|
+
GIT_CONFIG_GLOBAL: nullDevice,
|
|
597
|
+
GIT_CONFIG_SYSTEM: nullDevice,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function spawnGit(args, opts) {
|
|
601
|
+
const res = spawnSync('git', args, {
|
|
602
|
+
cwd: opts.cwd,
|
|
603
|
+
env: opts.env ?? process.env,
|
|
604
|
+
encoding: 'utf8',
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
status: res.status,
|
|
608
|
+
stdout: typeof res.stdout === 'string' ? res.stdout : String(res.stdout ?? ''),
|
|
609
|
+
stderr: typeof res.stderr === 'string' ? res.stderr : String(res.stderr ?? ''),
|
|
610
|
+
error: res.error,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function describeSpawn(res) {
|
|
614
|
+
const stderr = res.stderr.trim();
|
|
615
|
+
const stdout = res.stdout.trim();
|
|
616
|
+
if (res.error)
|
|
617
|
+
return `${res.error.message}${stderr ? ` | ${stderr}` : ''}`;
|
|
618
|
+
const status = res.status ?? 'n/a';
|
|
619
|
+
return [
|
|
620
|
+
`exit=${status}`,
|
|
621
|
+
stderr ? `stderr=${stderr}` : '',
|
|
622
|
+
stdout && !stderr ? `stdout=${stdout}` : '',
|
|
623
|
+
]
|
|
624
|
+
.filter((s) => s.length > 0)
|
|
625
|
+
.join(' ');
|
|
626
|
+
}
|
|
627
|
+
function safeStat(p) {
|
|
628
|
+
try {
|
|
629
|
+
return statSync(p);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Default readline-based prompt with a hard timeout. Resolves к the
|
|
637
|
+
* empty string on timeout, which the caller interprets as "operator
|
|
638
|
+
* declined". We deliberately do NOT use `process.stdin.once('data')`
|
|
639
|
+
* directly because that breaks Ctrl-C: readline gives us the standard
|
|
640
|
+
* signal handlers for free.
|
|
641
|
+
*
|
|
642
|
+
* Lifted out as a module-level helper so the spec can inject a stub
|
|
643
|
+
* via `RestoreCheckpointOptions.prompt` without touching real stdin.
|
|
644
|
+
*/
|
|
645
|
+
function defaultPrompt(question, timeoutMs) {
|
|
646
|
+
return new Promise((resolveOuter) => {
|
|
647
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
648
|
+
let settled = false;
|
|
649
|
+
const finish = (value) => {
|
|
650
|
+
if (settled)
|
|
651
|
+
return;
|
|
652
|
+
settled = true;
|
|
653
|
+
try {
|
|
654
|
+
rl.close();
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
/* ignore */
|
|
658
|
+
}
|
|
659
|
+
resolveOuter(value);
|
|
660
|
+
};
|
|
661
|
+
const timer = setTimeout(() => finish(''), Math.max(100, timeoutMs));
|
|
662
|
+
if (typeof timer.unref === 'function')
|
|
663
|
+
timer.unref();
|
|
664
|
+
rl.question(question, (answer) => {
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
finish(answer);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=shadow-git.js.map
|