@pellux/goodvibes-tui 1.27.0 → 2.0.1
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 +125 -2
- package/README.md +7 -9
- package/bin/goodvibes +2 -2
- package/bin/launcher-support.js +8 -4
- package/docs/README.md +11 -2
- package/docs/foundation-artifacts/operator-contract.json +6403 -2347
- package/package.json +14 -25
- package/scripts/postinstall.js +35 -35
- package/src/audio/spoken-turn-model-routing.ts +1 -1
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completions/generate.ts +1 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/entrypoint.ts +12 -7
- package/src/cli/help.ts +4 -5
- package/src/cli/hooks-command.ts +1 -1
- package/src/cli/index.ts +1 -4
- package/src/cli/launch-auto-update.ts +59 -7
- package/src/cli/management-commands.ts +6 -6
- package/src/cli/management-utils.ts +5 -5
- package/src/cli/management.ts +7 -7
- package/src/cli/network-posture.ts +1 -1
- package/src/cli/package-verification.ts +7 -6
- package/src/cli/plugin-bundles-command.ts +1 -1
- package/src/cli/plugin-command.ts +2 -2
- package/src/cli/service-command.ts +2 -2
- package/src/cli/service-posture.ts +4 -4
- package/src/cli/status.ts +9 -8
- package/src/cli/surface-command.ts +4 -4
- package/src/cli/tui-startup.ts +12 -3
- package/src/cli-flags.ts +3 -3
- package/src/config/goodvibes-home-audit.ts +0 -2
- package/src/config/secret-config.ts +37 -5
- package/src/config/tui-extension-settings.ts +1 -1
- package/src/core/approval-alert.ts +2 -2
- package/src/core/budget-breach-notifier.ts +3 -3
- package/src/core/conversation-compaction-render.ts +1 -1
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-render-context.ts +1 -1
- package/src/core/conversation-rendering.ts +2 -2
- package/src/core/conversation.ts +10 -10
- package/src/core/long-task-notifier.ts +2 -2
- package/src/core/memory-diagnostics-gateway.ts +1 -1
- package/src/core/scriptable-statusline.ts +1 -1
- package/src/core/session-auto-titler.ts +1 -1
- package/src/core/session-resume-core.ts +2 -2
- package/src/core/stream-event-wiring.ts +24 -44
- package/src/core/terminal-notifier.ts +3 -3
- package/src/core/turn-cancellation.ts +1 -1
- package/src/core/turn-event-wiring.ts +5 -5
- package/src/input/command-registry.ts +6 -6
- package/src/input/commands/channel-pairing.ts +1 -1
- package/src/input/commands/cluster-runtime.ts +1 -1
- package/src/input/commands/codebase-runtime.ts +1 -1
- package/src/input/commands/devices-runtime.ts +1 -1
- package/src/input/commands/hosted-runtime.ts +330 -0
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/local-setup-transfer.ts +2 -2
- package/src/input/commands/local-setup.ts +3 -3
- package/src/input/commands/managed-runtime.ts +2 -2
- package/src/input/commands/operator-rpc.ts +24 -110
- package/src/input/commands/payment-card-intake.ts +1 -1
- package/src/input/commands/permissions-provenance.ts +2 -2
- package/src/input/commands/plugin-runtime.ts +1 -1
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/relay-runtime.ts +18 -9
- package/src/input/commands/rewind-runtime.ts +1 -1
- package/src/input/commands/runtime-services.ts +1 -1
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/tasks-runtime.ts +145 -55
- package/src/input/commands/update-runtime.ts +31 -28
- package/src/input/commands/work-plan-runtime.ts +2 -2
- package/src/input/commands/workstream-attempts.ts +1 -1
- package/src/input/commands/workstream-runtime.ts +2 -2
- package/src/input/commands.ts +2 -0
- package/src/input/feature-unit-layout.ts +1 -1
- package/src/input/feed-context-factory.ts +3 -3
- package/src/input/handler-content-actions.ts +2 -2
- package/src/input/handler-feed-routes.ts +1 -1
- package/src/input/handler-feed.ts +3 -3
- package/src/input/handler-modal-token-routes.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +3 -2
- package/src/input/handler-onboarding.ts +1 -1
- package/src/input/handler-types.ts +2 -2
- package/src/input/handler-ui-state.ts +1 -1
- package/src/input/handler.ts +4 -4
- package/src/input/input-history.ts +1 -1
- package/src/input/onboarding/onboarding-feature-units.ts +2 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +15 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +17 -8
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +5 -3
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -1
- package/src/input/payments-config.ts +6 -6
- package/src/input/search.ts +1 -1
- package/src/input/session-picker-modal.ts +46 -0
- package/src/input/settings-modal-activation.ts +1 -1
- package/src/input/settings-modal-behavior.ts +4 -4
- package/src/input/settings-modal-commit.ts +11 -0
- package/src/input/settings-modal-data.ts +13 -80
- package/src/input/settings-modal-mutations.ts +33 -0
- package/src/input/settings-modal-secrets.ts +34 -0
- package/src/input/settings-modal-subscriptions.ts +1 -1
- package/src/input/settings-modal-types.ts +15 -1
- package/src/input/settings-modal.ts +28 -3
- package/src/input/worktree-setup-config.ts +1 -1
- package/src/main.ts +11 -9
- package/src/panels/agent-inspector-shared.ts +2 -2
- package/src/panels/base-panel.ts +2 -2
- package/src/panels/builtin/operations.ts +6 -8
- package/src/panels/builtin/session.ts +17 -0
- package/src/panels/builtin/shared.ts +5 -5
- package/src/panels/builtin-modals.ts +3 -3
- package/src/panels/confirm-state.ts +1 -1
- package/src/panels/cost-tracker-panel.ts +4 -4
- package/src/panels/diff-panel.ts +2 -2
- package/src/panels/diff-review-panel.ts +2 -2
- package/src/panels/expandable-list-panel.ts +2 -2
- package/src/panels/fleet-acts.ts +1 -1
- package/src/panels/fleet-observed-render.ts +1 -1
- package/src/panels/fleet-panel-format.ts +1 -1
- package/src/panels/fleet-panel.ts +1 -1
- package/src/panels/fleet-spawn.ts +1 -1
- package/src/panels/fleet-steer.ts +1 -1
- package/src/panels/fleet-transcript.ts +1 -1
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/hosted-session-feed.ts +352 -0
- package/src/panels/hosted-session-panel.ts +221 -0
- package/src/panels/local-auth-panel.ts +3 -3
- package/src/panels/modals/devices-modal.ts +1 -1
- package/src/panels/modals/local-auth-modal.ts +1 -1
- package/src/panels/modals/pairing-modal.ts +1 -1
- package/src/panels/modals/services-modal.ts +1 -1
- package/src/panels/modals/settings-sync-modal.ts +1 -1
- package/src/panels/modals/subscription-modal.ts +1 -1
- package/src/panels/modals/work-plan-modal.ts +1 -1
- package/src/panels/notifications-panel.ts +1 -1
- package/src/panels/panel-confirm-overlay.ts +1 -1
- package/src/panels/plugins-panel.ts +2 -2
- package/src/panels/polish-core.ts +2 -2
- package/src/panels/polish-tables.ts +1 -1
- package/src/panels/polish.ts +2 -2
- package/src/panels/scrollable-list-panel.ts +3 -3
- package/src/panels/skills-panel.ts +3 -3
- package/src/panels/token-budget-panel.ts +5 -5
- package/src/panels/types.ts +1 -2
- package/src/permissions/prompt.ts +1 -1
- package/src/renderer/autocomplete-overlay.ts +1 -1
- package/src/renderer/block-actions-overlay.ts +1 -1
- package/src/renderer/bookmark-modal.ts +1 -1
- package/src/renderer/buffer.ts +1 -1
- package/src/renderer/code-block.ts +1 -1
- package/src/renderer/composer-fragments.ts +1 -1
- package/src/renderer/compositor.ts +2 -2
- package/src/renderer/config-modal.ts +1 -1
- package/src/renderer/context-inspector.ts +1 -1
- package/src/renderer/conversation-layout.ts +2 -2
- package/src/renderer/conversation-overlays.ts +1 -1
- package/src/renderer/conversation-surface.ts +2 -2
- package/src/renderer/diff-view.ts +1 -1
- package/src/renderer/diff.ts +1 -1
- package/src/renderer/file-picker-overlay.ts +1 -1
- package/src/renderer/fleet-tab-strip.ts +1 -1
- package/src/renderer/fullscreen-primitives.ts +2 -2
- package/src/renderer/fullscreen-workspace.ts +1 -1
- package/src/renderer/header-line.ts +1 -1
- package/src/renderer/help-overlay.ts +1 -1
- package/src/renderer/history-search-overlay.ts +2 -2
- package/src/renderer/layout.ts +5 -8
- package/src/renderer/markdown-inline.ts +1 -1
- package/src/renderer/markdown-table.ts +1 -1
- package/src/renderer/markdown.ts +1 -1
- package/src/renderer/mcp-workspace.ts +1 -1
- package/src/renderer/modal-factory.ts +2 -2
- package/src/renderer/model-workspace.ts +2 -2
- package/src/renderer/onboarding/onboarding-wizard.ts +1 -1
- package/src/renderer/overlay-box.ts +1 -1
- package/src/renderer/panel-composite.ts +1 -1
- package/src/renderer/panel-workspace-bar.ts +1 -1
- package/src/renderer/process-indicator.ts +1 -1
- package/src/renderer/profile-picker-modal.ts +1 -1
- package/src/renderer/search-overlay.ts +2 -2
- package/src/renderer/selection-modal-overlay.ts +1 -1
- package/src/renderer/session-picker-modal.ts +124 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +2 -2
- package/src/renderer/shell-surface.ts +2 -2
- package/src/renderer/system-message.ts +1 -1
- package/src/renderer/tab-strip.ts +1 -1
- package/src/renderer/theme-mode-config.ts +1 -1
- package/src/renderer/theme.ts +11 -12
- package/src/renderer/thinking.ts +1 -1
- package/src/renderer/tool-call.ts +2 -2
- package/src/renderer/tool-result-expanded-lines.ts +1 -1
- package/src/renderer/turn-injection.ts +7 -8
- package/src/renderer/ui-factory.ts +3 -3
- package/src/runtime/bootstrap-command-context.ts +4 -4
- package/src/runtime/bootstrap-command-parts.ts +5 -5
- package/src/runtime/bootstrap-core.ts +30 -30
- package/src/runtime/bootstrap-shell.ts +4 -4
- package/src/runtime/bootstrap.ts +128 -157
- package/src/runtime/client/approval-updates.ts +82 -0
- package/src/runtime/client/build-floors.ts +167 -0
- package/src/runtime/client/daemon-attach-handshake.ts +115 -0
- package/src/runtime/client/fleet-union.ts +147 -0
- package/src/runtime/client/host-service-status.ts +71 -0
- package/src/runtime/client/hosted-exit.ts +86 -0
- package/src/runtime/client/hosted-roster.ts +110 -0
- package/src/runtime/client/hosted-session-stream.ts +178 -0
- package/src/runtime/client/hosted-sessions.ts +187 -0
- package/src/runtime/client/operator-endpoint.ts +213 -0
- package/src/runtime/client/spine-adoption.ts +99 -0
- package/src/runtime/cloudflare-control-plane.ts +1 -1
- package/src/runtime/context-accounting-source.ts +1 -1
- package/src/runtime/context.ts +2 -2
- package/src/runtime/conversation-rewind-port.ts +42 -14
- package/src/runtime/daemon-attach-notices.ts +55 -19
- package/src/runtime/diagnostics/panels/ops.ts +1 -1
- package/src/runtime/disposal-wiring.ts +4 -24
- package/src/runtime/index.ts +119 -105
- package/src/runtime/install-self-check-startup.ts +2 -2
- package/src/runtime/interaction-seams.ts +1 -1
- package/src/runtime/knowledge-services.ts +8 -2
- package/src/runtime/legacy-daemon-migration.ts +15 -3
- package/src/runtime/legacy-daemon-reconcile.ts +3 -2
- package/src/runtime/memory-spine-transport.ts +37 -267
- package/src/runtime/notification-dispatch.ts +2 -2
- package/src/runtime/onboarding/apply.ts +2 -2
- package/src/runtime/onboarding/derivation.ts +2 -2
- package/src/runtime/onboarding/markers.ts +2 -2
- package/src/runtime/onboarding/progress.ts +2 -2
- package/src/runtime/onboarding/snapshot.ts +1 -1
- package/src/runtime/onboarding/state.ts +2 -2
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/orchestrator-core-services.ts +1 -1
- package/src/runtime/path-shadow-startup.ts +28 -221
- package/src/runtime/power-keepawake-remote.ts +3 -2
- package/src/runtime/process-lifecycle.ts +2 -2
- package/src/runtime/provider-fallback.ts +10 -66
- package/src/runtime/recovery-autosave.ts +1 -1
- package/src/runtime/recovery-offer-wiring.ts +39 -23
- package/src/runtime/recovery-prompt.ts +31 -17
- package/src/runtime/relay-reachability-bridge.ts +53 -42
- package/src/runtime/release-artifacts.ts +2 -1
- package/src/runtime/runtime-services-types.ts +82 -35
- package/src/runtime/services.ts +305 -300
- package/src/runtime/session-resume-liveness-confirm.ts +1 -1
- package/src/runtime/ui-services.ts +7 -6
- package/src/runtime/update-check.ts +23 -43
- package/src/runtime/workspace-checkpointing.ts +1 -1
- package/src/runtime/wrfc-persistence.ts +2 -2
- package/src/shell/ui-openers.ts +30 -7
- package/src/utils/clipboard.ts +1 -1
- package/src/verification/verification-ledger.ts +10 -8
- package/src/version.ts +1 -1
- package/bin/goodvibes-daemon +0 -90
- package/src/cli/config-overrides.ts +0 -267
- package/src/cli/endpoints.ts +0 -109
- package/src/cli/parser.ts +0 -395
- package/src/cli/redaction.ts +0 -170
- package/src/cli/types.ts +0 -84
- package/src/cluster/commands.ts +0 -382
- package/src/cluster/remote-daemon-target.ts +0 -225
- package/src/cluster/render.ts +0 -268
- package/src/config/atomic-write.ts +0 -70
- package/src/config/credential-availability.ts +0 -122
- package/src/config/goodvibes-home.ts +0 -149
- package/src/config/index.ts +0 -88
- package/src/config/provider-model.ts +0 -23
- package/src/config/read-versioned.ts +0 -228
- package/src/core/alert-gating.ts +0 -67
- package/src/core/consolidation-receipt.ts +0 -41
- package/src/core/focus-tracker.ts +0 -41
- package/src/core/history.ts +0 -45
- package/src/core/memory-status.ts +0 -118
- package/src/core/orchestrator.ts +0 -7
- package/src/core/pairing-devices.ts +0 -49
- package/src/core/pairing-handoff.ts +0 -101
- package/src/core/pairing-offers.ts +0 -83
- package/src/core/pairing-origin.ts +0 -78
- package/src/core/rewind-turn-anchors.ts +0 -311
- package/src/core/session-recovery.ts +0 -210
- package/src/core/stable-host.ts +0 -171
- package/src/core/transcript-journal.ts +0 -503
- package/src/daemon/cli.ts +0 -641
- package/src/daemon/handlers/context.ts +0 -29
- package/src/daemon/handlers/contracts.ts +0 -77
- package/src/daemon/handlers/credentials.ts +0 -139
- package/src/daemon/handlers/drafts/draft-store.ts +0 -427
- package/src/daemon/handlers/drafts/index.ts +0 -17
- package/src/daemon/handlers/drafts/register.ts +0 -331
- package/src/daemon/handlers/errors.ts +0 -18
- package/src/daemon/handlers/inbox/cursor-store.ts +0 -457
- package/src/daemon/handlers/inbox/index.ts +0 -284
- package/src/daemon/handlers/inbox/mapping.ts +0 -192
- package/src/daemon/handlers/inbox/poller.ts +0 -212
- package/src/daemon/handlers/inbox/provider-adapter.ts +0 -156
- package/src/daemon/handlers/inbox/providers/discord.ts +0 -252
- package/src/daemon/handlers/inbox/providers/email.ts +0 -152
- package/src/daemon/handlers/inbox/providers/imap-client.ts +0 -300
- package/src/daemon/handlers/inbox/providers/route-util.ts +0 -24
- package/src/daemon/handlers/inbox/providers/slack.ts +0 -263
- package/src/daemon/handlers/index.ts +0 -117
- package/src/daemon/handlers/register.ts +0 -168
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +0 -142
- package/src/daemon/handlers/remote/backends/docker.ts +0 -79
- package/src/daemon/handlers/remote/backends/index.ts +0 -40
- package/src/daemon/handlers/remote/backends/local-process.ts +0 -113
- package/src/daemon/handlers/remote/backends/process-runner.ts +0 -127
- package/src/daemon/handlers/remote/backends/ssh.ts +0 -125
- package/src/daemon/handlers/remote/backends/types.ts +0 -97
- package/src/daemon/handlers/remote/dispatcher.ts +0 -181
- package/src/daemon/handlers/remote/index.ts +0 -119
- package/src/daemon/handlers/remote/peer-registry.ts +0 -357
- package/src/daemon/handlers/remote/service.ts +0 -191
- package/src/daemon/handlers/routing/inbox-bridge.ts +0 -71
- package/src/daemon/handlers/routing/index.ts +0 -261
- package/src/daemon/handlers/routing/route-store.ts +0 -319
- package/src/daemon/handlers/routing/routing-resolver.ts +0 -75
- package/src/daemon/handlers/sqlite-store.ts +0 -278
- package/src/daemon/handlers/triage/index.ts +0 -57
- package/src/daemon/handlers/triage/integration.ts +0 -213
- package/src/daemon/handlers/triage/pipeline.ts +0 -274
- package/src/daemon/handlers/triage/scorer.ts +0 -287
- package/src/daemon/handlers/triage/tagger/discord.ts +0 -187
- package/src/daemon/handlers/triage/tagger/imap.ts +0 -384
- package/src/daemon/handlers/triage/tagger/index.ts +0 -184
- package/src/daemon/handlers/triage/tagger/shared.ts +0 -70
- package/src/daemon/handlers/triage/tagger/slack.ts +0 -69
- package/src/daemon/handlers/triage/types.ts +0 -50
- package/src/daemon/lifecycle.ts +0 -41
- package/src/daemon/provision-wake-model.ts +0 -81
- package/src/daemon/safe-serve.ts +0 -61
- package/src/daemon/send/channels.ts +0 -200
- package/src/daemon/send/command.ts +0 -332
- package/src/daemon/send/composition.ts +0 -99
- package/src/daemon/send/failure-text.ts +0 -93
- package/src/daemon/send/inert-text.ts +0 -225
- package/src/daemon/send/stdin.ts +0 -24
- package/src/daemon/service-commands.ts +0 -329
- package/src/export/cost-utils.ts +0 -319
- package/src/input/selection.ts +0 -135
- package/src/plugins/loader.ts +0 -15
- package/src/renderer/bottom-bar.ts +0 -58
- package/src/renderer/conversation-tree.ts +0 -228
- package/src/runtime/agent-graph-composition.ts +0 -78
- package/src/runtime/channel-composition.ts +0 -46
- package/src/runtime/cluster-composition.ts +0 -124
- package/src/runtime/cluster-group-composition.ts +0 -284
- package/src/runtime/code-index-services.ts +0 -167
- package/src/runtime/daemon-handler-composition.ts +0 -76
- package/src/runtime/device-posture-composition.ts +0 -110
- package/src/runtime/durability-housekeeping.ts +0 -183
- package/src/runtime/durability-services.ts +0 -155
- package/src/runtime/embedded-service-factories.ts +0 -58
- package/src/runtime/feature-settings.ts +0 -154
- package/src/runtime/fleet-needs-input-push.ts +0 -70
- package/src/runtime/fleet-services.ts +0 -41
- package/src/runtime/idle-power-services.ts +0 -85
- package/src/runtime/mail-composition.ts +0 -65
- package/src/runtime/memory-fold.ts +0 -55
- package/src/runtime/operator-token-cleanup.ts +0 -109
- package/src/runtime/path-shadow.ts +0 -418
- package/src/runtime/perf/panel-health-monitor.ts +0 -18
- package/src/runtime/provider-optimizer-wiring.ts +0 -55
- package/src/runtime/reachability-notice.ts +0 -100
- package/src/runtime/remote-execution-composition.ts +0 -62
- package/src/runtime/session-liveness-marker.ts +0 -257
- package/src/runtime/session-pointer-surface.ts +0 -22
- package/src/runtime/session-storage-services.ts +0 -41
- package/src/runtime/surface-feature-flags.ts +0 -96
- package/src/runtime/terminal-output-guard.ts +0 -235
- package/src/runtime/trust/workspace-registration.ts +0 -192
- package/src/runtime/trust/workspace-trust.ts +0 -229
- package/src/runtime/ui/index.ts +0 -39
- package/src/runtime/ui/model-picker/data-provider.ts +0 -182
- package/src/runtime/ui/model-picker/health-enrichment.ts +0 -231
- package/src/runtime/ui/model-picker/index.ts +0 -59
- package/src/runtime/ui/model-picker/types.ts +0 -153
- package/src/runtime/ui/provider-health/data-provider.ts +0 -244
- package/src/runtime/ui/provider-health/fallback-visualizer.ts +0 -71
- package/src/runtime/ui/provider-health/index.ts +0 -46
- package/src/runtime/ui/provider-health/types.ts +0 -146
- package/src/runtime/ui-events.ts +0 -1
- package/src/runtime/ui-read-model-helpers.ts +0 -1
- package/src/runtime/ui-read-models-observability-maintenance.ts +0 -1
- package/src/runtime/ui-read-models-observability-options.ts +0 -1
- package/src/runtime/ui-read-models-observability-remote.ts +0 -1
- package/src/runtime/ui-read-models-observability-security.ts +0 -1
- package/src/runtime/ui-read-models-observability-system.ts +0 -1
- package/src/runtime/ui-read-models-observability.ts +0 -1
- package/src/runtime/ui-service-queries.ts +0 -1
- package/src/runtime/voice-setup-services.ts +0 -98
- package/src/runtime/workstream-attempts-validation.ts +0 -77
- package/src/runtime/workstream-draft-edits.ts +0 -95
- package/src/runtime/workstream-draft-store.ts +0 -274
- package/src/runtime/workstream-draft-types.ts +0 -42
- package/src/runtime/workstream-services.ts +0 -373
- package/src/tools/index.ts +0 -1
- package/src/types/grid.ts +0 -48
- package/src/types/sql-js.d.ts +0 -15
- package/src/work-plans/work-plan-store.ts +0 -702
|
@@ -1,702 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// work-plan-store.ts — per-project work plan persisted at
|
|
3
|
-
// <home>/.goodvibes/tui/work-plans/<projectId>.json
|
|
4
|
-
//
|
|
5
|
-
// Recovery discipline (applies to every read):
|
|
6
|
-
//
|
|
7
|
-
// * CONTENT, NOT EXISTENCE, decides whether the file is usable. A plan file
|
|
8
|
-
// can exist and still be unusable — zero-byte, truncated mid-write, or
|
|
9
|
-
// otherwise torn by a crash. Every read parses defensively and validates the
|
|
10
|
-
// parsed SHAPE; an unusable file degrades to an empty plan instead of
|
|
11
|
-
// throwing out of the store's public methods (which used to brick every
|
|
12
|
-
// /work-plan operation until the user deleted the file by hand).
|
|
13
|
-
// * AN UNREADABLE FILE IS PRESERVED, NOT OVERWRITTEN. It is renamed aside to
|
|
14
|
-
// `<plan>.json.corrupt-<ms>-<rand>` so a user can still recover their list.
|
|
15
|
-
// Those quarantine copies are themselves bounded (age TTL + count cap) so
|
|
16
|
-
// the recovery path cannot become its own leak.
|
|
17
|
-
// * THE STORE IS BOUNDED. Terminal (done/cancelled) items age out and are
|
|
18
|
-
// capped by count. Items the user still has open — pending, in_progress,
|
|
19
|
-
// blocked, failed — are NEVER reclaimed by either bound: garbage collection
|
|
20
|
-
// must not delete live work.
|
|
21
|
-
// * NOTHING IS DELETED SILENTLY. Whatever a sweep reclaimed is recorded on the
|
|
22
|
-
// plan as `housekeeping` and rendered by `toMarkdown()` (which /work-plan
|
|
23
|
-
// show prints), so a reclaim is distinguishable from data loss.
|
|
24
|
-
//
|
|
25
|
-
// Sweeps are idempotent and safe to run from more than one process: removal is
|
|
26
|
-
// computed from the file's own contents, writes go through atomicWriteFileSync
|
|
27
|
-
// (rename(2)), and unlink races are treated as already-done.
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
31
|
-
import { readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
32
|
-
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
33
|
-
import { basename, dirname, join } from 'node:path';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Age TTL for TERMINAL (done/cancelled) items. Once a completed or cancelled
|
|
37
|
-
* item has been untouched this long it is reclaimed by the next read. Chosen to
|
|
38
|
-
* comfortably outlive "I finished that last month and want to see it" while
|
|
39
|
-
* still bounding a plan that is never manually cleared.
|
|
40
|
-
*/
|
|
41
|
-
export const WORK_PLAN_TERMINAL_ITEM_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Count cap for TERMINAL (done/cancelled) items: the most recently completed
|
|
45
|
-
* this many are kept, older ones are reclaimed oldest-first. Live items are
|
|
46
|
-
* neither counted against this cap nor removed by it.
|
|
47
|
-
*/
|
|
48
|
-
export const WORK_PLAN_TERMINAL_ITEM_CAP = 200;
|
|
49
|
-
|
|
50
|
-
/** Age TTL for quarantined (unreadable) plan files — the recovery copy's own bound. */
|
|
51
|
-
export const WORK_PLAN_QUARANTINE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|
52
|
-
|
|
53
|
-
/** Count cap for quarantined plan files kept alongside one plan; oldest go first. */
|
|
54
|
-
export const WORK_PLAN_QUARANTINE_CAP = 5;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Minimum gap between quarantine-directory scans for one store instance. The
|
|
58
|
-
* item bounds are evaluated on every read (they are a pure pass over in-memory
|
|
59
|
-
* items); the directory scan is throttled so a redrawing modal cannot turn it
|
|
60
|
-
* into a per-frame readdir. This is what makes the sweep periodic rather than
|
|
61
|
-
* startup-only: a long-lived session keeps sweeping on this cadence.
|
|
62
|
-
*/
|
|
63
|
-
export const WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
64
|
-
|
|
65
|
-
/** Filename marker for a preserved copy of a plan file that could not be parsed. */
|
|
66
|
-
const QUARANTINE_SUFFIX = '.corrupt-';
|
|
67
|
-
|
|
68
|
-
export const WORK_PLAN_STATUSES = [
|
|
69
|
-
'pending',
|
|
70
|
-
'in_progress',
|
|
71
|
-
'blocked',
|
|
72
|
-
'done',
|
|
73
|
-
'failed',
|
|
74
|
-
'cancelled',
|
|
75
|
-
] as const;
|
|
76
|
-
|
|
77
|
-
export type WorkPlanItemStatus = typeof WORK_PLAN_STATUSES[number];
|
|
78
|
-
|
|
79
|
-
export interface WorkPlanLinkTargets {
|
|
80
|
-
readonly agentId?: string;
|
|
81
|
-
readonly wrfcId?: string;
|
|
82
|
-
readonly taskId?: string;
|
|
83
|
-
readonly sessionId?: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface WorkPlanItem {
|
|
87
|
-
readonly id: string;
|
|
88
|
-
readonly title: string;
|
|
89
|
-
readonly status: WorkPlanItemStatus;
|
|
90
|
-
readonly owner?: string;
|
|
91
|
-
readonly source?: string;
|
|
92
|
-
readonly notes?: string;
|
|
93
|
-
readonly linked?: WorkPlanLinkTargets;
|
|
94
|
-
readonly createdAt: number;
|
|
95
|
-
readonly updatedAt: number;
|
|
96
|
-
readonly completedAt?: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* What the last recovery/bounding sweep reclaimed. Persisted with the plan and
|
|
101
|
-
* rendered by `toMarkdown()` so a removal is always disclosed — counts and
|
|
102
|
-
* paths only, never item text.
|
|
103
|
-
*/
|
|
104
|
-
export interface WorkPlanHousekeeping {
|
|
105
|
-
/** Unix ms of the sweep that produced these counts. */
|
|
106
|
-
readonly at: number;
|
|
107
|
-
/** Terminal items reclaimed by the age TTL. */
|
|
108
|
-
readonly expiredItems: number;
|
|
109
|
-
/** Terminal items reclaimed by the count cap. */
|
|
110
|
-
readonly cappedItems: number;
|
|
111
|
-
/** True when the plan file on disk could not be parsed and the plan was reset. */
|
|
112
|
-
readonly resetFromUnreadableFile?: boolean;
|
|
113
|
-
/** Where the unreadable file was preserved, when there was content worth keeping. */
|
|
114
|
-
readonly quarantinePath?: string;
|
|
115
|
-
/** Quarantined files removed by their own TTL / count cap. */
|
|
116
|
-
readonly quarantinesRemoved?: number;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface WorkPlan {
|
|
120
|
-
readonly id: string;
|
|
121
|
-
readonly projectId: string;
|
|
122
|
-
readonly projectRoot: string;
|
|
123
|
-
readonly title: string;
|
|
124
|
-
readonly items: readonly WorkPlanItem[];
|
|
125
|
-
readonly activeItemId?: string;
|
|
126
|
-
readonly source?: string;
|
|
127
|
-
readonly createdAt: number;
|
|
128
|
-
readonly updatedAt: number;
|
|
129
|
-
/** Result of the most recent sweep that actually reclaimed something. */
|
|
130
|
-
readonly housekeeping?: WorkPlanHousekeeping;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface WorkPlanStoreOptions {
|
|
134
|
-
readonly homeDirectory: string;
|
|
135
|
-
readonly projectId: string;
|
|
136
|
-
readonly projectRoot: string;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface AddWorkPlanItemOptions {
|
|
140
|
-
readonly status?: WorkPlanItemStatus;
|
|
141
|
-
readonly owner?: string;
|
|
142
|
-
readonly source?: string;
|
|
143
|
-
readonly notes?: string;
|
|
144
|
-
readonly linked?: WorkPlanLinkTargets;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export interface UpdateWorkPlanItemPatch {
|
|
148
|
-
readonly title?: string;
|
|
149
|
-
readonly status?: WorkPlanItemStatus;
|
|
150
|
-
readonly owner?: string | null;
|
|
151
|
-
readonly source?: string | null;
|
|
152
|
-
readonly notes?: string | null;
|
|
153
|
-
readonly linked?: WorkPlanLinkTargets | null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function nowMs(): number {
|
|
157
|
-
return Date.now();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
161
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function readString(value: unknown): string | undefined {
|
|
165
|
-
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function isWorkPlanStatus(value: unknown): value is WorkPlanItemStatus {
|
|
169
|
-
return typeof value === 'string' && WORK_PLAN_STATUSES.includes(value as WorkPlanItemStatus);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function safeFileId(projectId: string, projectRoot: string): string {
|
|
173
|
-
const normalized = projectId.trim() || 'project';
|
|
174
|
-
const safe = normalized.replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
175
|
-
if (safe.length > 0 && safe.length <= 96) return safe;
|
|
176
|
-
const hash = createHash('sha256').update(`${projectId}\0${projectRoot}`).digest('hex').slice(0, 16);
|
|
177
|
-
return `${safe.slice(0, 80) || 'project'}-${hash}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function createPlanId(projectId: string, projectRoot: string): string {
|
|
181
|
-
const hash = createHash('sha256').update(`${projectId}\0${projectRoot}`).digest('hex').slice(0, 12);
|
|
182
|
-
return `wp-${hash}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function createItemId(): string {
|
|
186
|
-
return `wpi-${randomUUID().slice(0, 8)}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function normalizeLinked(value: unknown): WorkPlanLinkTargets | undefined {
|
|
190
|
-
if (!isObject(value)) return undefined;
|
|
191
|
-
const agentId = readString(value.agentId);
|
|
192
|
-
const wrfcId = readString(value.wrfcId);
|
|
193
|
-
const taskId = readString(value.taskId);
|
|
194
|
-
const sessionId = readString(value.sessionId);
|
|
195
|
-
const linked: WorkPlanLinkTargets = {
|
|
196
|
-
...(agentId ? { agentId } : {}),
|
|
197
|
-
...(wrfcId ? { wrfcId } : {}),
|
|
198
|
-
...(taskId ? { taskId } : {}),
|
|
199
|
-
...(sessionId ? { sessionId } : {}),
|
|
200
|
-
};
|
|
201
|
-
return Object.keys(linked).length > 0 ? linked : undefined;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function normalizeItem(value: unknown, fallbackCreatedAt: number): WorkPlanItem | null {
|
|
205
|
-
if (!isObject(value)) return null;
|
|
206
|
-
const title = readString(value.title);
|
|
207
|
-
if (!title) return null;
|
|
208
|
-
const status = isWorkPlanStatus(value.status) ? value.status : 'pending';
|
|
209
|
-
const createdAt = typeof value.createdAt === 'number' ? value.createdAt : fallbackCreatedAt;
|
|
210
|
-
const updatedAt = typeof value.updatedAt === 'number' ? value.updatedAt : createdAt;
|
|
211
|
-
const completedAt = typeof value.completedAt === 'number' ? value.completedAt : undefined;
|
|
212
|
-
const owner = readString(value.owner);
|
|
213
|
-
const source = readString(value.source);
|
|
214
|
-
const notes = readString(value.notes);
|
|
215
|
-
const linked = normalizeLinked(value.linked);
|
|
216
|
-
return {
|
|
217
|
-
id: readString(value.id) ?? createItemId(),
|
|
218
|
-
title,
|
|
219
|
-
status,
|
|
220
|
-
...(owner ? { owner } : {}),
|
|
221
|
-
...(source ? { source } : {}),
|
|
222
|
-
...(notes ? { notes } : {}),
|
|
223
|
-
...(linked ? { linked } : {}),
|
|
224
|
-
createdAt,
|
|
225
|
-
updatedAt,
|
|
226
|
-
...(completedAt !== undefined ? { completedAt } : {}),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function formatStatus(status: WorkPlanItemStatus): string {
|
|
231
|
-
return status.replace(/_/g, ' ');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Terminal = the user is done with it. Only these two statuses are eligible for
|
|
236
|
-
* the age TTL and the count cap. `blocked` and `failed` still need attention and
|
|
237
|
-
* `pending`/`in_progress` are live, so none of them are ever auto-reclaimed.
|
|
238
|
-
*/
|
|
239
|
-
function isTerminalStatus(status: WorkPlanItemStatus): boolean {
|
|
240
|
-
return status === 'done' || status === 'cancelled';
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** The timestamp a terminal item is aged from. */
|
|
244
|
-
function terminalAgeStamp(item: WorkPlanItem): number {
|
|
245
|
-
return item.completedAt ?? item.updatedAt ?? item.createdAt;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function normalizeHousekeeping(value: unknown): WorkPlanHousekeeping | undefined {
|
|
249
|
-
if (!isObject(value)) return undefined;
|
|
250
|
-
if (typeof value.at !== 'number') return undefined;
|
|
251
|
-
const expiredItems = typeof value.expiredItems === 'number' ? value.expiredItems : 0;
|
|
252
|
-
const cappedItems = typeof value.cappedItems === 'number' ? value.cappedItems : 0;
|
|
253
|
-
const quarantinePath = readString(value.quarantinePath);
|
|
254
|
-
const quarantinesRemoved = typeof value.quarantinesRemoved === 'number' ? value.quarantinesRemoved : undefined;
|
|
255
|
-
return {
|
|
256
|
-
at: value.at,
|
|
257
|
-
expiredItems,
|
|
258
|
-
cappedItems,
|
|
259
|
-
...(value.resetFromUnreadableFile === true ? { resetFromUnreadableFile: true } : {}),
|
|
260
|
-
...(quarantinePath ? { quarantinePath } : {}),
|
|
261
|
-
...(quarantinesRemoved !== undefined ? { quarantinesRemoved } : {}),
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Parse a plan file's CONTENT. Returns null for anything that is not a JSON
|
|
267
|
-
* object — empty/whitespace-only (a zero-byte file left by a crash), truncated
|
|
268
|
-
* JSON, or a valid JSON scalar/array. Never throws.
|
|
269
|
-
*/
|
|
270
|
-
function parsePlanDocument(raw: string): Record<string, unknown> | null {
|
|
271
|
-
if (raw.trim().length === 0) return null;
|
|
272
|
-
let parsed: unknown;
|
|
273
|
-
try {
|
|
274
|
-
parsed = JSON.parse(raw) as unknown;
|
|
275
|
-
} catch {
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
278
|
-
return isObject(parsed) ? parsed : null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** One-line, content-free disclosure of what the last sweep reclaimed. */
|
|
282
|
-
function formatHousekeeping(record: WorkPlanHousekeeping): string {
|
|
283
|
-
const parts: string[] = [];
|
|
284
|
-
if (record.resetFromUnreadableFile) {
|
|
285
|
-
parts.push(record.quarantinePath
|
|
286
|
-
? `the previous plan file could not be read and was preserved at ${record.quarantinePath}`
|
|
287
|
-
: 'the previous plan file was empty or unreadable and was reset');
|
|
288
|
-
}
|
|
289
|
-
if (record.expiredItems > 0) parts.push(`${record.expiredItems} completed item(s) aged out`);
|
|
290
|
-
if (record.cappedItems > 0) parts.push(`${record.cappedItems} completed item(s) over the retention cap removed`);
|
|
291
|
-
if (record.quarantinesRemoved && record.quarantinesRemoved > 0) {
|
|
292
|
-
parts.push(`${record.quarantinesRemoved} old quarantined plan file(s) deleted`);
|
|
293
|
-
}
|
|
294
|
-
if (parts.length === 0) parts.push('nothing reclaimed');
|
|
295
|
-
return `Housekeeping (${new Date(record.at).toISOString()}): ${parts.join('; ')}.`;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export function nextWorkPlanStatus(status: WorkPlanItemStatus): WorkPlanItemStatus {
|
|
299
|
-
switch (status) {
|
|
300
|
-
case 'pending':
|
|
301
|
-
return 'in_progress';
|
|
302
|
-
case 'in_progress':
|
|
303
|
-
return 'done';
|
|
304
|
-
case 'done':
|
|
305
|
-
return 'pending';
|
|
306
|
-
case 'blocked':
|
|
307
|
-
case 'failed':
|
|
308
|
-
case 'cancelled':
|
|
309
|
-
return 'pending';
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export class WorkPlanStore {
|
|
314
|
-
readonly filePath: string;
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Unix ms of this instance's last quarantine-directory scan. Zero means the
|
|
318
|
-
* first read of the session sweeps — that read IS the recovery point.
|
|
319
|
-
*/
|
|
320
|
-
private lastQuarantineSweepAt = 0;
|
|
321
|
-
|
|
322
|
-
constructor(private readonly options: WorkPlanStoreOptions) {
|
|
323
|
-
const fileName = `${safeFileId(options.projectId, options.projectRoot)}.json`;
|
|
324
|
-
this.filePath = join(options.homeDirectory, '.goodvibes', 'tui', 'work-plans', fileName);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
getActivePlan(): WorkPlan {
|
|
328
|
-
return this.readPlan();
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
listItems(): readonly WorkPlanItem[] {
|
|
332
|
-
return this.getActivePlan().items;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
addItem(title: string, options: AddWorkPlanItemOptions = {}): WorkPlanItem {
|
|
336
|
-
const normalizedTitle = title.trim();
|
|
337
|
-
if (!normalizedTitle) throw new Error('Work plan item title is required.');
|
|
338
|
-
const plan = this.readPlan();
|
|
339
|
-
const time = nowMs();
|
|
340
|
-
const item: WorkPlanItem = {
|
|
341
|
-
id: createItemId(),
|
|
342
|
-
title: normalizedTitle,
|
|
343
|
-
status: options.status ?? 'pending',
|
|
344
|
-
...(options.owner ? { owner: options.owner } : {}),
|
|
345
|
-
...(options.source ? { source: options.source } : {}),
|
|
346
|
-
...(options.notes ? { notes: options.notes } : {}),
|
|
347
|
-
...(options.linked ? { linked: options.linked } : {}),
|
|
348
|
-
createdAt: time,
|
|
349
|
-
updatedAt: time,
|
|
350
|
-
...(options.status === 'done' ? { completedAt: time } : {}),
|
|
351
|
-
};
|
|
352
|
-
this.writePlan({
|
|
353
|
-
...plan,
|
|
354
|
-
items: [...plan.items, item],
|
|
355
|
-
activeItemId: item.id,
|
|
356
|
-
updatedAt: time,
|
|
357
|
-
});
|
|
358
|
-
return item;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
updateItem(idOrPrefix: string, patch: UpdateWorkPlanItemPatch): WorkPlanItem {
|
|
362
|
-
const plan = this.readPlan();
|
|
363
|
-
const item = this.resolveItem(plan, idOrPrefix);
|
|
364
|
-
const time = nowMs();
|
|
365
|
-
const nextStatus = patch.status ?? item.status;
|
|
366
|
-
const next: WorkPlanItem = this.pruneItem({
|
|
367
|
-
...item,
|
|
368
|
-
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
|
|
369
|
-
status: nextStatus,
|
|
370
|
-
...(patch.owner !== undefined ? { owner: patch.owner || undefined } : {}),
|
|
371
|
-
...(patch.source !== undefined ? { source: patch.source || undefined } : {}),
|
|
372
|
-
...(patch.notes !== undefined ? { notes: patch.notes || undefined } : {}),
|
|
373
|
-
...(patch.linked !== undefined ? { linked: patch.linked || undefined } : {}),
|
|
374
|
-
updatedAt: time,
|
|
375
|
-
...(nextStatus === 'done' ? { completedAt: item.completedAt ?? time } : { completedAt: undefined }),
|
|
376
|
-
});
|
|
377
|
-
if (!next.title) throw new Error('Work plan item title is required.');
|
|
378
|
-
this.writePlan({
|
|
379
|
-
...plan,
|
|
380
|
-
items: plan.items.map((candidate) => candidate.id === item.id ? next : candidate),
|
|
381
|
-
activeItemId: next.id,
|
|
382
|
-
updatedAt: time,
|
|
383
|
-
});
|
|
384
|
-
return next;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
setItemStatus(idOrPrefix: string, status: WorkPlanItemStatus): WorkPlanItem {
|
|
388
|
-
return this.updateItem(idOrPrefix, { status });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
cycleItemStatus(idOrPrefix: string): WorkPlanItem {
|
|
392
|
-
const item = this.resolveItem(this.readPlan(), idOrPrefix);
|
|
393
|
-
return this.setItemStatus(item.id, nextWorkPlanStatus(item.status));
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
removeItem(idOrPrefix: string): WorkPlanItem {
|
|
397
|
-
const plan = this.readPlan();
|
|
398
|
-
const item = this.resolveItem(plan, idOrPrefix);
|
|
399
|
-
const time = nowMs();
|
|
400
|
-
const remaining = plan.items.filter((candidate) => candidate.id !== item.id);
|
|
401
|
-
this.writePlan({
|
|
402
|
-
...plan,
|
|
403
|
-
items: remaining,
|
|
404
|
-
activeItemId: remaining[0]?.id,
|
|
405
|
-
updatedAt: time,
|
|
406
|
-
});
|
|
407
|
-
return item;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
clearCompleted(): number {
|
|
411
|
-
const plan = this.readPlan();
|
|
412
|
-
const remaining = plan.items.filter((item) => item.status !== 'done' && item.status !== 'cancelled');
|
|
413
|
-
const removed = plan.items.length - remaining.length;
|
|
414
|
-
if (removed === 0) return 0;
|
|
415
|
-
this.writePlan({
|
|
416
|
-
...plan,
|
|
417
|
-
items: remaining,
|
|
418
|
-
activeItemId: remaining[0]?.id,
|
|
419
|
-
updatedAt: nowMs(),
|
|
420
|
-
});
|
|
421
|
-
return removed;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Writes the current plan's `toMarkdown()` output to a sibling `.md` file
|
|
426
|
-
* next to the JSON store file, so the checklist can be opened outside the
|
|
427
|
-
* TUI. Returns the written path alongside the markdown that was written.
|
|
428
|
-
*/
|
|
429
|
-
exportMarkdown(): { readonly path: string; readonly markdown: string } {
|
|
430
|
-
const plan = this.readPlan();
|
|
431
|
-
const markdown = this.toMarkdown(plan);
|
|
432
|
-
const path = this.filePath.replace(/\.json$/, '.md');
|
|
433
|
-
atomicWriteFileSync(path, `${markdown}\n`, { mkdirp: true });
|
|
434
|
-
return { path, markdown };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
toMarkdown(plan: WorkPlan = this.readPlan()): string {
|
|
438
|
-
const lines = [
|
|
439
|
-
`# ${plan.title}`,
|
|
440
|
-
'',
|
|
441
|
-
`Project: ${plan.projectRoot}`,
|
|
442
|
-
`Project ID: ${plan.projectId}`,
|
|
443
|
-
`Updated: ${new Date(plan.updatedAt).toISOString()}`,
|
|
444
|
-
];
|
|
445
|
-
// Disclosure: whatever the last sweep reclaimed is stated here, so a
|
|
446
|
-
// removal can never be mistaken for silent data loss.
|
|
447
|
-
if (plan.housekeeping) lines.push(formatHousekeeping(plan.housekeeping));
|
|
448
|
-
lines.push('');
|
|
449
|
-
if (plan.items.length === 0) {
|
|
450
|
-
lines.push('No work plan items recorded.');
|
|
451
|
-
return lines.join('\n');
|
|
452
|
-
}
|
|
453
|
-
for (const item of plan.items) {
|
|
454
|
-
const marker = item.status === 'done' ? 'x' : ' ';
|
|
455
|
-
const suffix = item.status === 'pending' ? '' : ` (${formatStatus(item.status)})`;
|
|
456
|
-
lines.push(`- [${marker}] ${item.title}${suffix}`);
|
|
457
|
-
if (item.owner) lines.push(` - Owner: ${item.owner}`);
|
|
458
|
-
if (item.source) lines.push(` - Source: ${item.source}`);
|
|
459
|
-
if (item.notes) lines.push(` - Notes: ${item.notes}`);
|
|
460
|
-
}
|
|
461
|
-
return lines.join('\n');
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* The plan as callers see it: loaded from disk (content-validated), then put
|
|
466
|
-
* through the recovery/bounding sweep. When the sweep reclaimed anything the
|
|
467
|
-
* bounded plan — carrying its housekeeping disclosure — is written back
|
|
468
|
-
* immediately, so the disclosure survives even if the caller only reads.
|
|
469
|
-
*/
|
|
470
|
-
private readPlan(): WorkPlan {
|
|
471
|
-
const loaded = this.loadFromDisk();
|
|
472
|
-
const swept = this.sweep(loaded.plan, loaded.recovery);
|
|
473
|
-
if (swept.changed) this.writePlan(swept.plan);
|
|
474
|
-
return swept.plan;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Read and validate the plan file's CONTENT. A missing file is the ordinary
|
|
479
|
-
* empty case. A file that exists but does not parse into a plan object is
|
|
480
|
-
* moved aside (never overwritten in place) and reported as a recovery.
|
|
481
|
-
*/
|
|
482
|
-
private loadFromDisk(): { plan: WorkPlan; recovery: WorkPlanHousekeeping | null } {
|
|
483
|
-
let raw: string;
|
|
484
|
-
try {
|
|
485
|
-
raw = readFileSync(this.filePath, 'utf8');
|
|
486
|
-
} catch {
|
|
487
|
-
// No file yet (the common first-run case), or it is unreadable right now.
|
|
488
|
-
// Nothing existed to preserve, so there is nothing to disclose.
|
|
489
|
-
return { plan: this.createEmptyPlan(), recovery: null };
|
|
490
|
-
}
|
|
491
|
-
const parsed = parsePlanDocument(raw);
|
|
492
|
-
if (parsed === null) {
|
|
493
|
-
// The file EXISTED but its contents are not a usable plan: zero-byte,
|
|
494
|
-
// truncated mid-write, or otherwise torn. Existence is not validity.
|
|
495
|
-
const quarantinePath = raw.trim().length > 0 ? this.quarantineUnreadableFile() : undefined;
|
|
496
|
-
return {
|
|
497
|
-
plan: this.createEmptyPlan(),
|
|
498
|
-
recovery: {
|
|
499
|
-
at: nowMs(),
|
|
500
|
-
expiredItems: 0,
|
|
501
|
-
cappedItems: 0,
|
|
502
|
-
resetFromUnreadableFile: true,
|
|
503
|
-
...(quarantinePath ? { quarantinePath } : {}),
|
|
504
|
-
},
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
const time = nowMs();
|
|
508
|
-
const createdAt = typeof parsed.createdAt === 'number' ? parsed.createdAt : time;
|
|
509
|
-
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : createdAt;
|
|
510
|
-
const items = Array.isArray(parsed.items)
|
|
511
|
-
? parsed.items.map((item) => normalizeItem(item, createdAt)).filter((item): item is WorkPlanItem => item !== null)
|
|
512
|
-
: [];
|
|
513
|
-
const activeItemId = readString(parsed.activeItemId);
|
|
514
|
-
const source = readString(parsed.source);
|
|
515
|
-
const housekeeping = normalizeHousekeeping(parsed.housekeeping);
|
|
516
|
-
return {
|
|
517
|
-
plan: {
|
|
518
|
-
id: readString(parsed.id) ?? createPlanId(this.options.projectId, this.options.projectRoot),
|
|
519
|
-
projectId: readString(parsed.projectId) ?? this.options.projectId,
|
|
520
|
-
projectRoot: readString(parsed.projectRoot) ?? this.options.projectRoot,
|
|
521
|
-
title: readString(parsed.title) ?? 'Work Plan',
|
|
522
|
-
items,
|
|
523
|
-
...(activeItemId && items.some((item) => item.id === activeItemId) ? { activeItemId } : {}),
|
|
524
|
-
...(source ? { source } : {}),
|
|
525
|
-
createdAt,
|
|
526
|
-
updatedAt,
|
|
527
|
-
...(housekeeping ? { housekeeping } : {}),
|
|
528
|
-
},
|
|
529
|
-
recovery: null,
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Apply both bounds (age TTL, then count cap) over TERMINAL items only, plus
|
|
535
|
-
* the throttled quarantine-file sweep. Pure with respect to disk except for
|
|
536
|
-
* quarantine deletions; the caller persists the result when `changed`.
|
|
537
|
-
*
|
|
538
|
-
* Idempotent: re-running it on the returned plan reclaims nothing further,
|
|
539
|
-
* which is what makes it safe to run concurrently from several processes.
|
|
540
|
-
*/
|
|
541
|
-
private sweep(
|
|
542
|
-
plan: WorkPlan,
|
|
543
|
-
recovery: WorkPlanHousekeeping | null,
|
|
544
|
-
): { plan: WorkPlan; changed: boolean } {
|
|
545
|
-
const now = nowMs();
|
|
546
|
-
const expired = new Set<WorkPlanItem>();
|
|
547
|
-
for (const item of plan.items) {
|
|
548
|
-
if (!isTerminalStatus(item.status)) continue;
|
|
549
|
-
if (now - terminalAgeStamp(item) > WORK_PLAN_TERMINAL_ITEM_TTL_MS) expired.add(item);
|
|
550
|
-
}
|
|
551
|
-
const survivors = plan.items.filter((item) => !expired.has(item));
|
|
552
|
-
// Count cap applies to terminal survivors only — newest completions kept.
|
|
553
|
-
const terminalSurvivors = survivors
|
|
554
|
-
.filter((item) => isTerminalStatus(item.status))
|
|
555
|
-
.sort((a, b) => terminalAgeStamp(b) - terminalAgeStamp(a));
|
|
556
|
-
const capped = new Set<WorkPlanItem>(terminalSurvivors.slice(WORK_PLAN_TERMINAL_ITEM_CAP));
|
|
557
|
-
const kept = survivors.filter((item) => !capped.has(item));
|
|
558
|
-
const quarantinesRemoved = this.sweepQuarantines(now);
|
|
559
|
-
|
|
560
|
-
const changed = expired.size > 0 || capped.size > 0 || recovery !== null || quarantinesRemoved > 0;
|
|
561
|
-
if (!changed) return { plan, changed: false };
|
|
562
|
-
|
|
563
|
-
const housekeeping: WorkPlanHousekeeping = {
|
|
564
|
-
at: now,
|
|
565
|
-
expiredItems: expired.size,
|
|
566
|
-
cappedItems: capped.size,
|
|
567
|
-
...(recovery?.resetFromUnreadableFile ? { resetFromUnreadableFile: true } : {}),
|
|
568
|
-
...(recovery?.quarantinePath ? { quarantinePath: recovery.quarantinePath } : {}),
|
|
569
|
-
...(quarantinesRemoved > 0 ? { quarantinesRemoved } : {}),
|
|
570
|
-
};
|
|
571
|
-
const activeItemId = plan.activeItemId && kept.some((item) => item.id === plan.activeItemId)
|
|
572
|
-
? plan.activeItemId
|
|
573
|
-
: undefined;
|
|
574
|
-
return {
|
|
575
|
-
plan: {
|
|
576
|
-
id: plan.id,
|
|
577
|
-
projectId: plan.projectId,
|
|
578
|
-
projectRoot: plan.projectRoot,
|
|
579
|
-
title: plan.title,
|
|
580
|
-
items: kept,
|
|
581
|
-
...(activeItemId ? { activeItemId } : {}),
|
|
582
|
-
...(plan.source ? { source: plan.source } : {}),
|
|
583
|
-
createdAt: plan.createdAt,
|
|
584
|
-
// `updatedAt` tracks the user's edits; a garbage-collection pass is not
|
|
585
|
-
// one, so it is deliberately left alone. housekeeping.at dates the sweep.
|
|
586
|
-
updatedAt: plan.updatedAt,
|
|
587
|
-
housekeeping,
|
|
588
|
-
},
|
|
589
|
-
changed: true,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Move an unparseable plan file aside so the user can still recover it.
|
|
595
|
-
* Returns the quarantine path, or undefined if the rename did not happen
|
|
596
|
-
* (already moved by a concurrent process, or the directory is not writable).
|
|
597
|
-
*/
|
|
598
|
-
private quarantineUnreadableFile(): string | undefined {
|
|
599
|
-
const target = `${this.filePath}${QUARANTINE_SUFFIX}${nowMs()}-${randomUUID().slice(0, 8)}`;
|
|
600
|
-
try {
|
|
601
|
-
renameSync(this.filePath, target);
|
|
602
|
-
return target;
|
|
603
|
-
} catch {
|
|
604
|
-
// Gone already (another process quarantined it first) or unwritable —
|
|
605
|
-
// either way the plan still degrades to empty rather than throwing.
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Bound the quarantine copies: delete any past WORK_PLAN_QUARANTINE_TTL_MS
|
|
612
|
-
* and any beyond WORK_PLAN_QUARANTINE_CAP (oldest first). Throttled to
|
|
613
|
-
* WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS per store instance so a long-lived
|
|
614
|
-
* session keeps sweeping without scanning the directory on every read.
|
|
615
|
-
*/
|
|
616
|
-
private sweepQuarantines(now: number): number {
|
|
617
|
-
if (now - this.lastQuarantineSweepAt < WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS) return 0;
|
|
618
|
-
this.lastQuarantineSweepAt = now;
|
|
619
|
-
const directory = dirname(this.filePath);
|
|
620
|
-
const prefix = `${basename(this.filePath)}${QUARANTINE_SUFFIX}`;
|
|
621
|
-
let entries: string[];
|
|
622
|
-
try {
|
|
623
|
-
entries = readdirSync(directory);
|
|
624
|
-
} catch {
|
|
625
|
-
return 0;
|
|
626
|
-
}
|
|
627
|
-
const candidates = entries
|
|
628
|
-
.filter((entry) => entry.startsWith(prefix))
|
|
629
|
-
.map((entry) => {
|
|
630
|
-
const stamp = Number.parseInt(entry.slice(prefix.length).split('-')[0] ?? '', 10);
|
|
631
|
-
return { entry, at: Number.isFinite(stamp) ? stamp : 0 };
|
|
632
|
-
})
|
|
633
|
-
.sort((a, b) => b.at - a.at); // newest first
|
|
634
|
-
let removed = 0;
|
|
635
|
-
for (let index = 0; index < candidates.length; index += 1) {
|
|
636
|
-
const candidate = candidates[index]!;
|
|
637
|
-
const tooOld = now - candidate.at > WORK_PLAN_QUARANTINE_TTL_MS;
|
|
638
|
-
const overCap = index >= WORK_PLAN_QUARANTINE_CAP;
|
|
639
|
-
if (!tooOld && !overCap) continue;
|
|
640
|
-
try {
|
|
641
|
-
unlinkSync(join(directory, candidate.entry));
|
|
642
|
-
removed += 1;
|
|
643
|
-
} catch {
|
|
644
|
-
// Already removed by a concurrent sweep — idempotent, not an error, and
|
|
645
|
-
// not counted so the disclosed number stays truthful.
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
return removed;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
private createEmptyPlan(): WorkPlan {
|
|
652
|
-
const time = nowMs();
|
|
653
|
-
return {
|
|
654
|
-
id: createPlanId(this.options.projectId, this.options.projectRoot),
|
|
655
|
-
projectId: this.options.projectId,
|
|
656
|
-
projectRoot: this.options.projectRoot,
|
|
657
|
-
title: 'Work Plan',
|
|
658
|
-
items: [],
|
|
659
|
-
source: 'tui',
|
|
660
|
-
createdAt: time,
|
|
661
|
-
updatedAt: time,
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
private writePlan(plan: WorkPlan): void {
|
|
666
|
-
atomicWriteFileSync(this.filePath, `${JSON.stringify(plan, null, 2)}\n`, { mkdirp: true });
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
private resolveItem(plan: WorkPlan, idOrPrefix: string): WorkPlanItem {
|
|
670
|
-
const needle = idOrPrefix.trim();
|
|
671
|
-
if (!needle) throw new Error('Work plan item id is required.');
|
|
672
|
-
const exact = plan.items.find((item) => item.id === needle);
|
|
673
|
-
if (exact) return exact;
|
|
674
|
-
const matches = plan.items.filter((item) => item.id.startsWith(needle));
|
|
675
|
-
if (matches.length === 1) return matches[0]!;
|
|
676
|
-
if (matches.length > 1) {
|
|
677
|
-
throw new Error(`Work plan item id "${needle}" is ambiguous: ${matches.map((item) => item.id).join(', ')}`);
|
|
678
|
-
}
|
|
679
|
-
throw new Error(`Work plan item not found: ${needle}`);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
private pruneItem(item: WorkPlanItem & {
|
|
683
|
-
owner?: string | undefined;
|
|
684
|
-
source?: string | undefined;
|
|
685
|
-
notes?: string | undefined;
|
|
686
|
-
linked?: WorkPlanLinkTargets | undefined;
|
|
687
|
-
completedAt?: number | undefined;
|
|
688
|
-
}): WorkPlanItem {
|
|
689
|
-
return {
|
|
690
|
-
id: item.id,
|
|
691
|
-
title: item.title,
|
|
692
|
-
status: item.status,
|
|
693
|
-
...(item.owner ? { owner: item.owner } : {}),
|
|
694
|
-
...(item.source ? { source: item.source } : {}),
|
|
695
|
-
...(item.notes ? { notes: item.notes } : {}),
|
|
696
|
-
...(item.linked ? { linked: item.linked } : {}),
|
|
697
|
-
createdAt: item.createdAt,
|
|
698
|
-
updatedAt: item.updatedAt,
|
|
699
|
-
...(item.completedAt !== undefined ? { completedAt: item.completedAt } : {}),
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
}
|