heyspex 0.1.0 → 0.2.0
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/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wip-ledger-health — visibility + restore for the WIP hand-off ledger
|
|
4
|
+
* (`.heyspex/wip-state.json`) and its archive (`.heyspex/wip-archive/*.jsonl`).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the ledger is a JSON object of `epic id -> entry` that only
|
|
7
|
+
* gets pruned/archived on a WRITE (a peer lane, [[HS-B8OK-PFMN]], is adding
|
|
8
|
+
* age-based archive/shrink rules to that write path). Nobody can see the
|
|
9
|
+
* ledger's health — line count against the 600-line budget, how many entries
|
|
10
|
+
* are aging toward archival, whether one entry is disproportionately large —
|
|
11
|
+
* without waiting for a write to warn about it. This module answers that
|
|
12
|
+
* read-only, and gives a way to pull an archived entry back onto the live
|
|
13
|
+
* ledger without hand-editing JSON.
|
|
14
|
+
*
|
|
15
|
+
* Three faces share this core (per the "capability = core → MCP + CLI" rule):
|
|
16
|
+
* - hs CLI `hs wip status` / `hs wip restore <id>` (bin/hs.mjs → this file)
|
|
17
|
+
* - `node scripts/wip-ledger-health.mjs status|restore …` direct invocation
|
|
18
|
+
*
|
|
19
|
+
* Every exported function is pure w.r.t. process state — no `process.exit`
|
|
20
|
+
* anywhere outside `main()` (Windows + Node 24: an exit mid-request throws
|
|
21
|
+
* away the real exit code — this repo already removed ~150 such calls). Every
|
|
22
|
+
* function also takes `ledgerPath`/`archiveDir`/`now` as arguments, defaulting
|
|
23
|
+
* to the real `.heyspex/` paths and the real clock ONLY inside `main()`, so
|
|
24
|
+
* tests never touch the real vault and never depend on the wall clock.
|
|
25
|
+
*
|
|
26
|
+
* Run directly:
|
|
27
|
+
* node scripts/wip-ledger-health.mjs status [--json]
|
|
28
|
+
* node scripts/wip-ledger-health.mjs restore <id> [--json] [--force]
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
|
+
|
|
34
|
+
import { getAuthHeader, makeConsoleFetch } from "../vendor/heyspex-mcp/console-fetch.mjs";
|
|
35
|
+
import { resolveConsoleUrl } from "./lib/console-url-resolver.mjs";
|
|
36
|
+
import { resolveDevMode } from "./lib/dev-mode.mjs";
|
|
37
|
+
|
|
38
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
40
|
+
|
|
41
|
+
export const BUDGET_LINES = 600;
|
|
42
|
+
// Age tiers: fresh [0, FRESH_MAX_DAYS], aging (FRESH_MAX_DAYS, STALE_MIN_DAYS],
|
|
43
|
+
// stale (STALE_MIN_DAYS, ∞) — boundaries are inclusive on the LOWER tier,
|
|
44
|
+
// mirroring the write path's own `age > cutoff` comparisons exactly (an entry
|
|
45
|
+
// sitting exactly ON a cutoff has not yet crossed it).
|
|
46
|
+
//
|
|
47
|
+
// These numbers MUST match the real cutoffs the write path enforces:
|
|
48
|
+
// `WIP_COMPACT_AFTER_DAYS` (shrink window start) and `WIP_STALE_AFTER_DAYS`
|
|
49
|
+
// (archive-out threshold) in spex-console/src/lib/wip-archive.ts — the write
|
|
50
|
+
// path shrinks an entry in place for `age > compactAfterDays && age <=
|
|
51
|
+
// staleAfterDays` and archives it out for `age > staleAfterDays`. This file
|
|
52
|
+
// stays plain Node with no dependency on that TypeScript module, so the two
|
|
53
|
+
// constant sets can't be imported into sync — if either cutoff there moves,
|
|
54
|
+
// move FRESH_MAX_DAYS / STALE_MIN_DAYS here too, or this health report will
|
|
55
|
+
// describe a different ledger than the one the write path is about to produce.
|
|
56
|
+
export const FRESH_MAX_DAYS = 2;
|
|
57
|
+
export const STALE_MIN_DAYS = 7;
|
|
58
|
+
|
|
59
|
+
export const EXIT = { OK: 0, REFUSED: 1, NOT_FOUND: 1, BAD_INVOCATION: 2 };
|
|
60
|
+
|
|
61
|
+
// ---- path defaults (real filesystem only reached from main()) -------------
|
|
62
|
+
|
|
63
|
+
export function defaultVaultRoot() {
|
|
64
|
+
return process.env.HS_VAULT_ROOT || PKG_ROOT;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function defaultLedgerPath(vaultRoot = defaultVaultRoot()) {
|
|
68
|
+
return path.join(vaultRoot, ".heyspex", "wip-state.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function defaultArchiveDir(vaultRoot = defaultVaultRoot()) {
|
|
72
|
+
return path.join(vaultRoot, ".heyspex", "wip-archive");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- age classification ----------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify an age in days into 'fresh' | 'aging' | 'stale'. Never touches the
|
|
79
|
+
* clock — callers compute `ageDays` via `ageDaysFrom` against an explicit `now`.
|
|
80
|
+
*
|
|
81
|
+
* Boundaries are `<=`, not `<`, to mirror the write path's own `age > cutoff`
|
|
82
|
+
* checks exactly: an entry sitting at exactly FRESH_MAX_DAYS has not yet
|
|
83
|
+
* crossed into the shrink window (write path leaves it alone), and one at
|
|
84
|
+
* exactly STALE_MIN_DAYS has not yet crossed into archive-out (write path
|
|
85
|
+
* still just shrinks it). See the constant comments above for where the real
|
|
86
|
+
* cutoffs live.
|
|
87
|
+
*/
|
|
88
|
+
export function classifyAge(ageDays) {
|
|
89
|
+
if (ageDays <= FRESH_MAX_DAYS) return "fresh";
|
|
90
|
+
if (ageDays <= STALE_MIN_DAYS) return "aging";
|
|
91
|
+
return "stale";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ageDaysFrom(updatedAtIso, now = new Date()) {
|
|
95
|
+
const nowMs = now instanceof Date ? now.getTime() : now;
|
|
96
|
+
const thenMs = new Date(updatedAtIso).getTime();
|
|
97
|
+
return (nowMs - thenMs) / (24 * 60 * 60 * 1000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Count newline characters the way `wc -l` does (no off-by-one on a trailing newline). */
|
|
101
|
+
export function countLines(raw) {
|
|
102
|
+
const matches = raw.match(/\n/g);
|
|
103
|
+
return matches ? matches.length : 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- ledger read ------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read + parse the live ledger. Never throws — every failure mode (missing
|
|
110
|
+
* file, unreadable, unparseable, wrong shape) comes back as `{ ok: false, error }`
|
|
111
|
+
* so callers can print a clear message instead of a stack trace.
|
|
112
|
+
*/
|
|
113
|
+
export function readLedger(ledgerPath) {
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = readFileSync(ledgerPath, "utf8");
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err && err.code === "ENOENT") {
|
|
119
|
+
return { ok: false, error: `ledger file not found: ${ledgerPath}` };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `could not read ledger file ${ledgerPath}: ${err.message}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(raw);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: `ledger file ${ledgerPath} is not valid JSON: ${err.message}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: `ledger file ${ledgerPath} does not contain a JSON object of entries`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, data, raw, lineCount: countLines(raw) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- archive read -----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function listArchiveFiles(archiveDir) {
|
|
147
|
+
try {
|
|
148
|
+
return readdirSync(archiveDir)
|
|
149
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
150
|
+
.sort();
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readArchiveSummary(archiveDir) {
|
|
157
|
+
const files = listArchiveFiles(archiveDir);
|
|
158
|
+
let totalSizeBytes = 0;
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
try {
|
|
161
|
+
totalSizeBytes += statSync(path.join(archiveDir, f)).size;
|
|
162
|
+
} catch {
|
|
163
|
+
// A file that vanished between readdir and stat doesn't count. Not an
|
|
164
|
+
// error worth surfacing in a health report.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
fileCount: files.length,
|
|
169
|
+
totalSizeBytes,
|
|
170
|
+
totalSizeKB: totalSizeBytes / 1024,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Scan every `.heyspex/wip-archive/*.jsonl` file for records `{ id, archivedAt,
|
|
176
|
+
* entry }` matching `id`, and return the one with the newest `archivedAt`
|
|
177
|
+
* (or null if `id` was never archived). Malformed lines/files are skipped,
|
|
178
|
+
* never thrown.
|
|
179
|
+
*/
|
|
180
|
+
function findNewestArchived(archiveDir, id) {
|
|
181
|
+
const files = listArchiveFiles(archiveDir);
|
|
182
|
+
let newest = null;
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
const filePath = path.join(archiveDir, file);
|
|
185
|
+
let content;
|
|
186
|
+
try {
|
|
187
|
+
content = readFileSync(filePath, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const line of content.split("\n")) {
|
|
192
|
+
const trimmed = line.trim();
|
|
193
|
+
if (!trimmed) continue;
|
|
194
|
+
let record;
|
|
195
|
+
try {
|
|
196
|
+
record = JSON.parse(trimmed);
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!record || record.id !== id) continue;
|
|
201
|
+
const ts = Date.parse(record.archivedAt);
|
|
202
|
+
const newestTs = newest ? Date.parse(newest.archivedAt) : -Infinity;
|
|
203
|
+
if (!newest || ts > newestTs) {
|
|
204
|
+
newest = {
|
|
205
|
+
archivedAt: record.archivedAt,
|
|
206
|
+
entry: record.entry,
|
|
207
|
+
sourceFile: file,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return newest;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- status report ----------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the `hs wip status` health report. Pure: takes `ledgerPath`,
|
|
219
|
+
* `archiveDir`, and `now` explicitly; never reads the wall clock itself.
|
|
220
|
+
*/
|
|
221
|
+
export function buildReport({
|
|
222
|
+
ledgerPath,
|
|
223
|
+
archiveDir,
|
|
224
|
+
now = new Date(),
|
|
225
|
+
budgetLines = BUDGET_LINES,
|
|
226
|
+
}) {
|
|
227
|
+
const ledgerResult = readLedger(ledgerPath);
|
|
228
|
+
if (!ledgerResult.ok) {
|
|
229
|
+
return { ok: false, error: ledgerResult.error, ledgerPath };
|
|
230
|
+
}
|
|
231
|
+
const { data, lineCount } = ledgerResult;
|
|
232
|
+
const ids = Object.keys(data);
|
|
233
|
+
|
|
234
|
+
const tiers = { fresh: 0, aging: 0, stale: 0 };
|
|
235
|
+
let oldest = null;
|
|
236
|
+
let largest = null;
|
|
237
|
+
|
|
238
|
+
for (const id of ids) {
|
|
239
|
+
const entry = data[id] ?? {};
|
|
240
|
+
const ageDays = ageDaysFrom(entry.updatedAt, now);
|
|
241
|
+
tiers[classifyAge(ageDays)] += 1;
|
|
242
|
+
|
|
243
|
+
if (!oldest || ageDays > oldest.ageDays) {
|
|
244
|
+
oldest = { id, ageDays };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
248
|
+
if (!largest || sizeBytes > largest.sizeBytes) {
|
|
249
|
+
largest = { id, sizeBytes, sizeKB: sizeBytes / 1024 };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
ledgerPath,
|
|
256
|
+
lineCount,
|
|
257
|
+
budgetLines,
|
|
258
|
+
overBudget: lineCount > budgetLines,
|
|
259
|
+
overUnderLines: lineCount - budgetLines,
|
|
260
|
+
entryCount: ids.length,
|
|
261
|
+
tiers,
|
|
262
|
+
oldest: oldest ? { id: oldest.id, ageDays: oldest.ageDays } : null,
|
|
263
|
+
largest: largest
|
|
264
|
+
? { id: largest.id, sizeKB: largest.sizeKB }
|
|
265
|
+
: null,
|
|
266
|
+
archive: readArchiveSummary(archiveDir),
|
|
267
|
+
checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function formatHumanReport(report) {
|
|
272
|
+
if (!report.ok) {
|
|
273
|
+
return `wip status: ${report.error}`;
|
|
274
|
+
}
|
|
275
|
+
const overUnderWord = report.overBudget ? "over" : "under";
|
|
276
|
+
const overUnderCount = Math.abs(report.overUnderLines);
|
|
277
|
+
const lines = [
|
|
278
|
+
`ledger: ${report.ledgerPath}`,
|
|
279
|
+
`lines: ${report.lineCount} / ${report.budgetLines} budget (${overUnderCount} lines ${overUnderWord} budget)`,
|
|
280
|
+
`entries: ${report.entryCount}`,
|
|
281
|
+
`age tiers: fresh ${report.tiers.fresh} (<=${FRESH_MAX_DAYS}d), aging ${report.tiers.aging} (${FRESH_MAX_DAYS}-${STALE_MIN_DAYS}d), stale ${report.tiers.stale} (>${STALE_MIN_DAYS}d)`,
|
|
282
|
+
report.oldest
|
|
283
|
+
? `oldest entry: ${report.oldest.id} (${report.oldest.ageDays.toFixed(1)} days old)`
|
|
284
|
+
: "oldest entry: (ledger is empty)",
|
|
285
|
+
report.largest
|
|
286
|
+
? `largest entry: ${report.largest.id} (${report.largest.sizeKB.toFixed(1)} KB)`
|
|
287
|
+
: "largest entry: (ledger is empty)",
|
|
288
|
+
`archive: ${report.archive.fileCount} file(s), ${report.archive.totalSizeKB.toFixed(1)} KB total`,
|
|
289
|
+
];
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- restore ------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Pull the newest archived record for `id` back onto the live ledger.
|
|
297
|
+
* - Refuses (code 1) when `id` is already live, unless `force`.
|
|
298
|
+
* - Not-found (code 1) when `id` was never archived.
|
|
299
|
+
* - On success, writes the ledger back with the same 2-space formatting +
|
|
300
|
+
* trailing newline the file already uses, so the diff is only the
|
|
301
|
+
* restored entry.
|
|
302
|
+
*/
|
|
303
|
+
export function restoreEntry({
|
|
304
|
+
id,
|
|
305
|
+
ledgerPath,
|
|
306
|
+
archiveDir,
|
|
307
|
+
force = false,
|
|
308
|
+
now = new Date(),
|
|
309
|
+
}) {
|
|
310
|
+
const ledgerResult = readLedger(ledgerPath);
|
|
311
|
+
if (!ledgerResult.ok) {
|
|
312
|
+
return { ok: false, code: 1, message: ledgerResult.error };
|
|
313
|
+
}
|
|
314
|
+
const { data } = ledgerResult;
|
|
315
|
+
|
|
316
|
+
const archived = findNewestArchived(archiveDir, id);
|
|
317
|
+
if (!archived) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
code: 1,
|
|
321
|
+
message: `no archived entry found for ${id} under ${archiveDir}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const hadLive = Object.prototype.hasOwnProperty.call(data, id);
|
|
326
|
+
if (hadLive && !force) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
code: 1,
|
|
330
|
+
message:
|
|
331
|
+
`refusing to overwrite live entry for ${id} — ` +
|
|
332
|
+
`live updatedAt=${data[id]?.updatedAt ?? "unknown"}, ` +
|
|
333
|
+
`archived updatedAt=${archived.entry?.updatedAt ?? "unknown"} ` +
|
|
334
|
+
`(archived ${archived.archivedAt} in ${archived.sourceFile}). ` +
|
|
335
|
+
`Pass --force to overwrite.`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
data[id] = archived.entry;
|
|
340
|
+
const newRaw = JSON.stringify(data, null, 2) + "\n";
|
|
341
|
+
writeFileSync(ledgerPath, newRaw, "utf8");
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
code: 0,
|
|
346
|
+
id,
|
|
347
|
+
archivedAt: archived.archivedAt,
|
|
348
|
+
sourceFile: archived.sourceFile,
|
|
349
|
+
ledgerPath,
|
|
350
|
+
newLineCount: countLines(newRaw),
|
|
351
|
+
overwritten: hadLive,
|
|
352
|
+
checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function formatHumanRestore(result) {
|
|
357
|
+
if (!result.ok) {
|
|
358
|
+
return `wip restore: ${result.message}`;
|
|
359
|
+
}
|
|
360
|
+
const verb = result.overwritten ? "restored (overwrote live entry)" : "restored";
|
|
361
|
+
return (
|
|
362
|
+
`${verb}: ${result.id}\n` +
|
|
363
|
+
` archivedAt: ${result.archivedAt}\n` +
|
|
364
|
+
` from: ${result.sourceFile}\n` +
|
|
365
|
+
` ledger: ${result.ledgerPath} (now ${result.newLineCount} lines)`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- cloud-mode dispatch ----------------------------------------------------
|
|
370
|
+
//
|
|
371
|
+
// `hs wip status`/`hs wip restore` used to be local-filesystem-only: no HTTP,
|
|
372
|
+
// no cloud awareness, so either command against a cloud vault either read a
|
|
373
|
+
// stale/absent local file or silently reported "not found" even though the
|
|
374
|
+
// ledger actually lives in Neon (`wip_ledger` / `wip_ledger_archive` —
|
|
375
|
+
// `spex-console/src/lib/wip-state.ts` + `wip-ledger-archive-neon.ts`). This
|
|
376
|
+
// section detects cloud reliance and, when it is unambiguously configured,
|
|
377
|
+
// dispatches to the console's HTTP API instead of touching the filesystem —
|
|
378
|
+
// mirroring the transport `scripts/lib/hs-companion-settings.mjs`'s
|
|
379
|
+
// `makeCompanionConsoleFetch` already uses (shared `resolveConsoleUrl` + the
|
|
380
|
+
// shared MCP bearer ladder from `vendor/heyspex-mcp/console-fetch.mjs`; there
|
|
381
|
+
// is no WIP-specific loopback token equivalent to `COMPANION_UI_TOKEN`, so the
|
|
382
|
+
// ladder here is just the shared one).
|
|
383
|
+
//
|
|
384
|
+
// Reliance detection is deliberately conservative: `resolveDevMode`
|
|
385
|
+
// (`scripts/lib/dev-mode.mjs`) defaults an UNCONFIGURED machine to
|
|
386
|
+
// prod/cloud (cloud is the default reliance for a HeySpex customer; local is
|
|
387
|
+
// opt-in for a HeySpex admin's own dev machine — see the repo's CLAUDE.md).
|
|
388
|
+
// That default is exactly the case a hermetic test run or a CI box hits (no
|
|
389
|
+
// `HEYSPEX_DEV_MODE` env, no `~/.heyspex/dev-mode.json`), and this file's own
|
|
390
|
+
// existing tests assume LOCAL file behavior with nothing configured. So an
|
|
391
|
+
// UNRESOLVED default is treated as local here — only an EXPLICITLY resolved
|
|
392
|
+
// cloud reliance (`sources.reliance` naming an env override, a machine-local
|
|
393
|
+
// `dev-mode.json` override, or a cloud account setting — anything but
|
|
394
|
+
// "default") switches this CLI to the HTTP path. This keeps every existing
|
|
395
|
+
// local-mode call site (including this file's own test suite) byte-identical
|
|
396
|
+
// when nothing about dev/cloud mode has been configured.
|
|
397
|
+
|
|
398
|
+
/** True only when reliance was EXPLICITLY resolved to "cloud" (never the unconfigured default — see the note above). */
|
|
399
|
+
export function isCloudReliance({ env = process.env, homeDir } = {}) {
|
|
400
|
+
try {
|
|
401
|
+
const resolved = resolveDevMode(homeDir ? { env, homeDir } : { env });
|
|
402
|
+
if (resolved?.sources?.reliance === "default") return false;
|
|
403
|
+
return resolved?.reliance === "cloud";
|
|
404
|
+
} catch {
|
|
405
|
+
return false; // fail open to the historical local-file behavior.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The console fetch this command uses in cloud mode: vendor transport + the
|
|
411
|
+
* shared URL resolver + the shared MCP bearer ladder (`HEYSPEX_MCP_TOKEN` env
|
|
412
|
+
* → `~/.heyspex/credentials.yaml` `mcp_token`) — the same building blocks
|
|
413
|
+
* `makeCompanionConsoleFetch` in `scripts/lib/hs-companion-settings.mjs` uses.
|
|
414
|
+
*/
|
|
415
|
+
export function makeWipConsoleFetch({
|
|
416
|
+
url = null,
|
|
417
|
+
env = process.env,
|
|
418
|
+
homeDir,
|
|
419
|
+
vaultRoot,
|
|
420
|
+
fetchImpl,
|
|
421
|
+
} = {}) {
|
|
422
|
+
const getConsoleUrl = () =>
|
|
423
|
+
(
|
|
424
|
+
url ??
|
|
425
|
+
resolveConsoleUrl({
|
|
426
|
+
repoRoot: vaultRoot ?? env.HS_VAULT_ROOT ?? process.cwd(),
|
|
427
|
+
env,
|
|
428
|
+
...(homeDir ? { homeDir } : {}),
|
|
429
|
+
})
|
|
430
|
+
).replace(/\/+$/, "");
|
|
431
|
+
return makeConsoleFetch({
|
|
432
|
+
getConsoleUrl,
|
|
433
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
434
|
+
getAuthHeader: () => getAuthHeader(homeDir ? { env, homeDir } : { env }),
|
|
435
|
+
}).consoleFetch;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** One-line failure text for a non-ok transport result (vendor console-fetch shape). */
|
|
439
|
+
function describeCloudFailure(result, verb = "GET") {
|
|
440
|
+
if (!result || result.status === 0) {
|
|
441
|
+
const detail = result?.error ? ` (${result.error})` : "";
|
|
442
|
+
return result?.authMessage ?? `console unreachable at ${result?.url ?? "?"}${detail}`;
|
|
443
|
+
}
|
|
444
|
+
const body = result.body;
|
|
445
|
+
const msg =
|
|
446
|
+
(body && typeof body === "object" && (body.message ?? body.error ?? body.raw)) ??
|
|
447
|
+
(typeof body === "string" ? body : JSON.stringify(body));
|
|
448
|
+
return `${verb} failed ${result.status}: ${msg}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Cloud-mode `hs wip restore <id>` — POSTs
|
|
453
|
+
* `/api/specs/:id/wip/restore` (`spex-console/src/app/api/specs/[id]/wip/restore/route.ts`)
|
|
454
|
+
* instead of touching a local file. The result shape is close enough to
|
|
455
|
+
* {@link restoreEntry}'s that {@link formatHumanCloudRestore} reads the same
|
|
456
|
+
* way; there is no `ledgerPath`/`sourceFile` in cloud mode, so `consoleUrl`
|
|
457
|
+
* stands in for both.
|
|
458
|
+
*/
|
|
459
|
+
export async function restoreEntryCloud({ id, force = false, consoleFetch }) {
|
|
460
|
+
const res = await consoleFetch(`/api/specs/${encodeURIComponent(id)}/wip/restore`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
body: { force },
|
|
463
|
+
});
|
|
464
|
+
if (res.ok) {
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
code: 0,
|
|
468
|
+
id,
|
|
469
|
+
archivedAt: res.body?.archivedAt ?? null,
|
|
470
|
+
overwritten: res.body?.overwritten === true,
|
|
471
|
+
consoleUrl: res.url,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
code: 1,
|
|
477
|
+
message: describeCloudFailure(res, "POST"),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function formatHumanCloudRestore(result) {
|
|
482
|
+
if (!result.ok) {
|
|
483
|
+
return `wip restore: ${result.message}`;
|
|
484
|
+
}
|
|
485
|
+
const verb = result.overwritten ? "restored (overwrote live entry)" : "restored";
|
|
486
|
+
return (
|
|
487
|
+
`${verb}: ${result.id}\n` +
|
|
488
|
+
` archivedAt: ${result.archivedAt}\n` +
|
|
489
|
+
` console: ${result.consoleUrl}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Cloud-mode `hs wip status` — builds a report shaped closely enough to
|
|
495
|
+
* {@link buildReport}'s that {@link formatHumanCloudReport} reads the same
|
|
496
|
+
* way, from `GET /api/wip` (the whole ledger for the caller's vault) instead
|
|
497
|
+
* of a local file. There is no cloud route for an archive summary today, so
|
|
498
|
+
* `archive` is reported `null` rather than fabricated — the
|
|
499
|
+
* `/api/cron/wip-archive-purge` retention job is the only visibility into the
|
|
500
|
+
* cloud archive right now.
|
|
501
|
+
*/
|
|
502
|
+
export async function buildCloudReport({
|
|
503
|
+
consoleFetch,
|
|
504
|
+
now = new Date(),
|
|
505
|
+
budgetLines = BUDGET_LINES,
|
|
506
|
+
}) {
|
|
507
|
+
const res = await consoleFetch("/api/wip");
|
|
508
|
+
if (!res.ok) {
|
|
509
|
+
return { ok: false, error: describeCloudFailure(res, "GET"), consoleUrl: res.url };
|
|
510
|
+
}
|
|
511
|
+
const state =
|
|
512
|
+
res.body?.wip_state && typeof res.body.wip_state === "object" ? res.body.wip_state : {};
|
|
513
|
+
const ids = Object.keys(state);
|
|
514
|
+
|
|
515
|
+
const tiers = { fresh: 0, aging: 0, stale: 0 };
|
|
516
|
+
let oldest = null;
|
|
517
|
+
let largest = null;
|
|
518
|
+
for (const id of ids) {
|
|
519
|
+
const entry = state[id] ?? {};
|
|
520
|
+
const ageDays = ageDaysFrom(entry.updatedAt, now);
|
|
521
|
+
tiers[classifyAge(ageDays)] += 1;
|
|
522
|
+
if (!oldest || ageDays > oldest.ageDays) oldest = { id, ageDays };
|
|
523
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
524
|
+
if (!largest || sizeBytes > largest.sizeBytes) {
|
|
525
|
+
largest = { id, sizeBytes, sizeKB: sizeBytes / 1024 };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
consoleUrl: res.url,
|
|
532
|
+
budgetLines,
|
|
533
|
+
entryCount: ids.length,
|
|
534
|
+
tiers,
|
|
535
|
+
oldest: oldest ? { id: oldest.id, ageDays: oldest.ageDays } : null,
|
|
536
|
+
largest: largest ? { id: largest.id, sizeKB: largest.sizeKB } : null,
|
|
537
|
+
archive: null,
|
|
538
|
+
checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function formatHumanCloudReport(report) {
|
|
543
|
+
if (!report.ok) return `wip status: ${report.error}`;
|
|
544
|
+
const lines = [
|
|
545
|
+
`console: ${report.consoleUrl}`,
|
|
546
|
+
`entries: ${report.entryCount}`,
|
|
547
|
+
`age tiers: fresh ${report.tiers.fresh} (<=${FRESH_MAX_DAYS}d), aging ${report.tiers.aging} (${FRESH_MAX_DAYS}-${STALE_MIN_DAYS}d), stale ${report.tiers.stale} (>${STALE_MIN_DAYS}d)`,
|
|
548
|
+
report.oldest
|
|
549
|
+
? `oldest entry: ${report.oldest.id} (${report.oldest.ageDays.toFixed(1)} days old)`
|
|
550
|
+
: "oldest entry: (ledger is empty)",
|
|
551
|
+
report.largest
|
|
552
|
+
? `largest entry: ${report.largest.id} (${report.largest.sizeKB.toFixed(1)} KB)`
|
|
553
|
+
: "largest entry: (ledger is empty)",
|
|
554
|
+
"archive: cloud archive summary not available from this CLI yet — see the wip-archive-purge cron for retention",
|
|
555
|
+
];
|
|
556
|
+
return lines.join("\n");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---- CLI entrypoint ---------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
function usageText() {
|
|
562
|
+
return [
|
|
563
|
+
"hs wip — WIP ledger health + restore",
|
|
564
|
+
"",
|
|
565
|
+
" hs wip status [--json] Health report on the live ledger",
|
|
566
|
+
" hs wip restore <id> [--json] [--force] Restore an archived entry onto the live ledger",
|
|
567
|
+
"",
|
|
568
|
+
" Against a cloud vault (explicitly configured cloud reliance), both",
|
|
569
|
+
" subcommands talk to the console's HTTP API instead of the local file.",
|
|
570
|
+
].join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function main(argv = process.argv.slice(2)) {
|
|
574
|
+
const [sub, ...rest] = argv;
|
|
575
|
+
const asJson = rest.includes("--json");
|
|
576
|
+
const vaultRoot = defaultVaultRoot();
|
|
577
|
+
const ledgerPath = defaultLedgerPath(vaultRoot);
|
|
578
|
+
const archiveDir = defaultArchiveDir(vaultRoot);
|
|
579
|
+
|
|
580
|
+
const emit = (text, code) => {
|
|
581
|
+
const stream = code === 0 ? process.stdout : process.stderr;
|
|
582
|
+
stream.write(text.endsWith("\n") ? text : text + "\n");
|
|
583
|
+
process.exit(code);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const cloud = isCloudReliance();
|
|
587
|
+
|
|
588
|
+
if (sub === "status") {
|
|
589
|
+
if (cloud) {
|
|
590
|
+
const consoleFetch = makeWipConsoleFetch({ vaultRoot });
|
|
591
|
+
buildCloudReport({ consoleFetch }).then(
|
|
592
|
+
(report) => {
|
|
593
|
+
if (asJson) {
|
|
594
|
+
emit(JSON.stringify(report, null, 2), report.ok ? 0 : 1);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
emit(formatHumanCloudReport(report), report.ok ? 0 : 1);
|
|
598
|
+
},
|
|
599
|
+
(err) => emit(`wip status: ${err?.message ?? err}`, 1),
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const report = buildReport({ ledgerPath, archiveDir });
|
|
604
|
+
if (asJson) {
|
|
605
|
+
emit(JSON.stringify(report, null, 2), report.ok ? 0 : 1);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
emit(formatHumanReport(report), report.ok ? 0 : 1);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (sub === "restore") {
|
|
613
|
+
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
614
|
+
const id = positional[0];
|
|
615
|
+
const force = rest.includes("--force");
|
|
616
|
+
if (!id) {
|
|
617
|
+
emit(
|
|
618
|
+
"hs wip restore: missing <id>.\n" + usageText(),
|
|
619
|
+
EXIT.BAD_INVOCATION,
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (cloud) {
|
|
624
|
+
const consoleFetch = makeWipConsoleFetch({ vaultRoot });
|
|
625
|
+
restoreEntryCloud({ id, force, consoleFetch }).then(
|
|
626
|
+
(result) => {
|
|
627
|
+
if (asJson) {
|
|
628
|
+
emit(JSON.stringify(result, null, 2), result.ok ? 0 : result.code);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
emit(formatHumanCloudRestore(result), result.ok ? 0 : result.code);
|
|
632
|
+
},
|
|
633
|
+
(err) => emit(`wip restore: ${err?.message ?? err}`, 1),
|
|
634
|
+
);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const result = restoreEntry({ id, ledgerPath, archiveDir, force });
|
|
638
|
+
if (asJson) {
|
|
639
|
+
emit(JSON.stringify(result, null, 2), result.ok ? 0 : result.code);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
emit(formatHumanRestore(result), result.ok ? 0 : result.code);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
emit(usageText(), EXIT.BAD_INVOCATION);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const invokedDirectly =
|
|
650
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
651
|
+
if (invokedDirectly) {
|
|
652
|
+
main();
|
|
653
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"edition": "baseline",
|
|
4
|
+
"learnedAt": "2026-09-11T10:05:22.053Z",
|
|
5
|
+
"curatedAt": "2026-10-03",
|
|
6
|
+
"_comment": "Baseline edition of SpexCode Doctrine: the foundational layer that ships in the heyspex npm package and is injected into customer sessions (owner ruling 2026-10-03, task HS-A2PO). Every principle is copied verbatim from the learned set (spexharness/doctrine/SPEXCODE-DOCTRINE.json, learnedAt above), with its confidence kept and its evidence ids dropped (they name the owner's own sessions). Selection rule: generic engineering practice only. Never add a rule about the spex-console dev server, Neon, the COO seat, dev-factory, the owner's own machine or preferences, or any heyspex-app path; spexharness/src/doctrine/inject.test.mjs fails if one appears. A customer's own learned layer is built on top of this file, never written into it.",
|
|
7
|
+
"sources": {
|
|
8
|
+
"curatedFrom": "spexharness/doctrine/SPEXCODE-DOCTRINE.json",
|
|
9
|
+
"principleCountInSource": 1481
|
|
10
|
+
},
|
|
11
|
+
"principles": [
|
|
12
|
+
{ "text": "Treat a spec as done only after its own gate has run and exact pass/fail counts are reported.", "category": "verification", "confidence": 0.92, "stance": "do", "evidence": [] },
|
|
13
|
+
{ "text": "Before reporting done, re-check every item in the stated range, not just the one flagged as missed, and explain the cause of any gap.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
|
|
14
|
+
{ "text": "Back every claimed acceptance criterion with something actually run — tests, typecheck, lint, or measured timings — instead of asserting completion.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
|
|
15
|
+
{ "text": "Never claim a build complete or flip status on faith; only after typecheck and the real tests are green.", "category": "verification", "confidence": 0.88, "stance": "avoid", "evidence": [] },
|
|
16
|
+
{ "text": "When an exit test is unmet or a regression is found, say so explicitly in the artefact instead of papering over it or closing on a number you set out to beat and did not.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
17
|
+
{ "text": "Report exactly what was not done, could not be done, or failed, and name the blocker, rather than glossing over an untested branch or a red acceptance criterion.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
18
|
+
{ "text": "Touch only the files in scope; when an out-of-scope edit is unavoidable, keep it minimal and flag it explicitly.", "category": "session-hygiene", "confidence": 0.88, "stance": "do", "evidence": [] },
|
|
19
|
+
{ "text": "When a design question is open, research it with the designated tools instead of guessing an answer or a number.", "category": "research-grounding", "confidence": 0.88, "stance": "do", "evidence": [] },
|
|
20
|
+
{ "text": "Confirming an outline or stub is not doing the work; fill every section with real sourced content before reporting research done.", "category": "research-grounding", "confidence": 0.85, "stance": "avoid", "evidence": [] },
|
|
21
|
+
{ "text": "Irreversible or transactional writes are two-phase: present a proposal and take a real human confirmation turn before executing, never in the same turn the proposal is authored.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
22
|
+
{ "text": "Run at the most restrictive permission mode until the folder is trusted, and never auto-approve a block-severity gate.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
23
|
+
{ "text": "Disclose known out-of-scope failures, deliberate deviations, and residual risk in the final report rather than burying them.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
24
|
+
{ "text": "End every close-out or status report with an explicit per-item outcome word (fixed, partly fixed, not fixed, not started), stated before the detail, so a found bug is never left ambiguous.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
25
|
+
{ "text": "Session titles stay short and descriptive in plain words, never raw spec/task ids, and remain sticky through follow-ups and sub-steps rather than changing at each turn.", "category": "naming", "confidence": 0.85, "stance": "do", "evidence": [] },
|
|
26
|
+
{ "text": "Parallelise independent workstreams by spawning sub-agents instead of serialising everything in the main thread.", "category": "delegation", "confidence": 0.84, "stance": "do", "evidence": [] }
|
|
27
|
+
],
|
|
28
|
+
"conflicts": []
|
|
29
|
+
}
|