heyspex 0.1.0 → 0.2.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/.claude-plugin/marketplace.json +14 -0
- package/README.md +21 -0
- package/bin/hs.mjs +1089 -104
- 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 +1127 -103
- package/dist/cli/hs-claim.mjs +981 -63
- package/dist/cli/hs-connect.mjs +10059 -1729
- package/dist/cli/hs-defer.mjs +5507 -3072
- package/dist/cli/hs-dev.mjs +576 -0
- package/dist/cli/hs-env.mjs +416 -302
- package/dist/cli/hs-index-rebuild.mjs +7334 -0
- package/dist/cli/hs-index-reconcile.mjs +8138 -0
- package/dist/cli/hs-init.mjs +2840 -149
- package/dist/cli/hs-install.mjs +7214 -594
- package/dist/cli/hs-join.mjs +10640 -2421
- package/dist/cli/hs-vault-pull.mjs +8157 -6370
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +4029 -1555
- package/dist/cli/hs-workspace.mjs +2702 -538
- 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 +30 -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 +28 -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 +33 -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 +32 -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/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +249 -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/README.md +309 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +80 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
- package/plugins/heyspex/hooks/hooks.json +418 -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 +456 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
- package/scripts/claude-hooks/_run.mjs +413 -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-execution-gate.mjs +51 -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 +577 -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/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -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 +86 -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 +279 -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 +451 -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/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
- package/scripts/cursor-hooks/manifest.mjs +281 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +288 -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 +496 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +272 -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 +199 -61
- package/scripts/lib/customer-kit.mjs +1203 -0
- package/scripts/lib/dev-mode.mjs +249 -32
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-branch.mjs +196 -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 +13 -22
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -307
- 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/lib/window-placement.mjs +109 -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 +449 -0
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -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/_shell-write-targets.mjs +77 -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 +275 -0
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -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/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +196 -0
- package/spexharness/src/intent-directive.mjs +626 -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 +849 -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 +96 -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 +77 -8
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
- package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
- package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
- package/vendor/heyspex-mcp/cli.mjs +207 -15524
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -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 +822 -17
- package/vendor/heyspex-mcp/console-transport.mjs +172 -0
- package/vendor/heyspex-mcp/credentials.mjs +585 -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 +131 -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 +723 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
- package/vendor/heyspex-mcp/http-transport.mjs +302 -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 +107 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -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 +908 -22
- 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 +327 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
- package/vendor/heyspex-mcp/promote-capture.mjs +388 -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 +666 -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 +191 -15
- 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 +83 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +625 -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/roadmap-default.mjs +70 -0
- 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 +109 -0
- package/vendor/heyspex-mcp/session-tools.mjs +162 -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 +453 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +304 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -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 +720 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +92 -0
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dev-factory/lib/worktree-allocator.mjs
|
|
3
|
+
// CLAIM-BOUND WORKTREE ALLOCATION — the session-scoped layer on top of the build-scoped
|
|
4
|
+
// engine primitives in dev-factory/engine/worktree.mjs.
|
|
5
|
+
// Task: HS-L4Q5 (epic HS-03DO "Claim-bound worktree isolation for parallel agent sessions").
|
|
6
|
+
//
|
|
7
|
+
// The engine owns ONE lifecycle: a short-lived, detached, per-candidate gate worktree that the
|
|
8
|
+
// dev-factory loop creates and tears down inside a single build (marker `kind: "build"`). This
|
|
9
|
+
// module owns a SECOND, longer-lived lifecycle: a worktree bound to an agent's CLAIM on a spec
|
|
10
|
+
// (marker `kind: "session"`), on its own `claim/<FILE KEY>` branch, which outlives any single
|
|
11
|
+
// build and is reclaimed only when the claim is released / the owner dies / the console says the
|
|
12
|
+
// claim is no longer active — and never while it holds uncommitted work.
|
|
13
|
+
//
|
|
14
|
+
// Everything here is built ON the engine primitives (makeCtx / setupWorktree / teardownWorktree /
|
|
15
|
+
// sweepStaleWorktrees / makeLink / git / isPidAlive / OWNER_FILE), never forked from them. The
|
|
16
|
+
// hard-won Windows lessons therefore apply unchanged:
|
|
17
|
+
// - node_modules is JUNCTIONED (root + workspace), never copied.
|
|
18
|
+
// - git always runs with core.longpaths=true (engine `git()`).
|
|
19
|
+
// - teardown removes the junction LINKS first, THEN `git worktree remove` — never a recursive
|
|
20
|
+
// delete through a live junction (that deletes the REAL node_modules).
|
|
21
|
+
// - Windows paths compare case-insensitively.
|
|
22
|
+
//
|
|
23
|
+
// Every exported function takes the engine `ctx` (from makeCtx) plus an injectable
|
|
24
|
+
// `deps = { git, fs, now, isPidAlive, claimIsActive, fetch, consoleUrl }` so the whole surface is
|
|
25
|
+
// testable without touching the real repo or the real console. Left unset, `claimIsActive` is
|
|
26
|
+
// backed by the console's /api/claims (createConsoleClaimChecker) — one round trip per reap.
|
|
27
|
+
//
|
|
28
|
+
// LIVENESS IS THREE-VALUED, and that is the safety property this module turns on. A session's
|
|
29
|
+
// owner pid is normally UNKNOWN (`null`), because on Windows the launcher that created the
|
|
30
|
+
// worktree exits seconds after the agent starts; and the console can answer "no claim", "claimed",
|
|
31
|
+
// or nothing at all. Only POSITIVE evidence of death — a release marker, a pid that is provably
|
|
32
|
+
// gone, or a successful console read that lists no claim — makes a worktree reclaimable, and only
|
|
33
|
+
// then if it holds nothing uncommitted.
|
|
34
|
+
//
|
|
35
|
+
// CLI:
|
|
36
|
+
// node dev-factory/lib/worktree-allocator.mjs <list|alloc|release|reap|rebind> [flags]
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync, realpathSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import { join, resolve, basename } from "node:path";
|
|
42
|
+
import {
|
|
43
|
+
makeCtx, setupWorktree, teardownWorktree, sweepStaleWorktrees,
|
|
44
|
+
OWNER_FILE, isPidAlive, makeLink, git, slug,
|
|
45
|
+
} from "../engine/worktree.mjs";
|
|
46
|
+
import { resolveConsoleUrl } from "../../scripts/lib/console-url-resolver.mjs";
|
|
47
|
+
|
|
48
|
+
// Re-exported so callers of this module never need to reach past it into the engine.
|
|
49
|
+
export { makeCtx, setupWorktree, teardownWorktree, sweepStaleWorktrees, OWNER_FILE };
|
|
50
|
+
|
|
51
|
+
// Stable exit codes for the CLI mode below.
|
|
52
|
+
// mirrors spex-console/src/lib/cli/control-shared.ts
|
|
53
|
+
const EXIT_OK = 0; // success
|
|
54
|
+
const EXIT_USAGE = 1; // unknown verb/flag, malformed command
|
|
55
|
+
const EXIT_CHECK_FAILED = 2; // a read-only check ran correctly and its answer is "no"
|
|
56
|
+
const EXIT_INVALID_ARG = 11; // a required argument is missing or unparseable
|
|
57
|
+
const EXIT_WRITE_FAILED = 13; // the write itself failed (git / fs)
|
|
58
|
+
|
|
59
|
+
/** How long an UNCLAIMED `launch-*` placeholder worktree may sit untouched before `reap` treats
|
|
60
|
+
* it as abandoned. Long enough that a human who opened a window yesterday evening and came back
|
|
61
|
+
* this morning still finds it; short enough that a week of false starts does not accumulate. */
|
|
62
|
+
export const LAUNCH_PLACEHOLDER_TTL_MS = 24 * 60 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/** Hard deadline on the one console round trip a reap makes. Reaping is housekeeping — it must
|
|
65
|
+
* never hang on an unreachable console; an unanswered probe is simply "unknown". */
|
|
66
|
+
export const CLAIM_PROBE_TIMEOUT_MS = 4000;
|
|
67
|
+
|
|
68
|
+
/** The real implementations every function falls back to. Kept as ONE object so a test can
|
|
69
|
+
* spread-override a single member (`{ ...DEFAULT_DEPS, git: spy }`) without losing the rest. */
|
|
70
|
+
export const DEFAULT_DEPS = {
|
|
71
|
+
git,
|
|
72
|
+
fs: { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync },
|
|
73
|
+
now: () => new Date(),
|
|
74
|
+
isPidAlive,
|
|
75
|
+
// `null`, not a function: `reapWorktrees` then builds a FRESH console-backed checker for that
|
|
76
|
+
// one call (createConsoleClaimChecker below), so the claim list is fetched exactly once per
|
|
77
|
+
// reap no matter how many worktrees it walks. A caller that supplies its own function — a test,
|
|
78
|
+
// or an offline caller wanting the old "always unknown" behaviour — overrides it here.
|
|
79
|
+
claimIsActive: null,
|
|
80
|
+
// Injection points for that checker; undefined → global fetch and the resolved console URL.
|
|
81
|
+
fetch: undefined,
|
|
82
|
+
consoleUrl: undefined,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const withDeps = (deps) => ({ ...DEFAULT_DEPS, ...(deps || {}) });
|
|
86
|
+
|
|
87
|
+
// Windows compares paths case-insensitively, `git worktree list` reports forward slashes, and a
|
|
88
|
+
// path under %TEMP% may reach us as an 8.3 SHORT NAME (C:\Users\PWHER~1\...) from one side and
|
|
89
|
+
// long-form from the other — three ways for the SAME directory to look like two. Every path
|
|
90
|
+
// identity check (and the list's dedup key) goes through this one canonicalisation.
|
|
91
|
+
function canonicalPath(p) {
|
|
92
|
+
if (!p) return "";
|
|
93
|
+
let out = resolve(String(p));
|
|
94
|
+
try { out = realpathSync.native(out); } catch { /* not on disk (a pruned registration) — resolve() is the best we have */ }
|
|
95
|
+
return out.replace(/[\\/]+$/, "").toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
const samePath = (a, b) => !!a && !!b && canonicalPath(a) === canonicalPath(b);
|
|
98
|
+
|
|
99
|
+
// ── key / label / branch derivation ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** FNV-1a (32-bit), returned as 8 lowercase hex chars. `fnv1a32("")` is the offset basis,
|
|
102
|
+
* 811c9dc5 — the standard's own test vector, so a drift in this function is visible. */
|
|
103
|
+
export function fnv1a32(input) {
|
|
104
|
+
let h = 0x811c9dc5;
|
|
105
|
+
const s = String(input ?? "");
|
|
106
|
+
for (let i = 0; i < s.length; i++) {
|
|
107
|
+
h ^= s.charCodeAt(i);
|
|
108
|
+
// 32-bit multiply by the FNV prime (16777619) without losing the high bits to float64.
|
|
109
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
110
|
+
}
|
|
111
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Deterministic dev-server port for a worktree key: 3001..3050 inclusive, never 3000 (the
|
|
115
|
+
* primary checkout's port — a session worktree must never collide with it). */
|
|
116
|
+
export function devServerPortFor(key) {
|
|
117
|
+
return 3000 + (parseInt(fnv1a32(key).slice(0, 4), 16) % 50) + 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Split a spec id into the pieces the allocator names things with.
|
|
122
|
+
*
|
|
123
|
+
* Naming V3: `HS-<file key>-<family key>-<TYPE>-<slug>` — the token right after `HS-` is the
|
|
124
|
+
* unique FILE ID. A bare `launch-<id>` key (an unfiled ad-hoc session) has no `HS-` prefix and
|
|
125
|
+
* is used as-is, slugged.
|
|
126
|
+
*
|
|
127
|
+
* → `{ key, label, branch, launch }`
|
|
128
|
+
*/
|
|
129
|
+
export function keyInfoFor(specId) {
|
|
130
|
+
const raw = String(specId ?? "").trim();
|
|
131
|
+
const m = /^HS-([A-Za-z0-9]+)/i.exec(raw);
|
|
132
|
+
if (m) {
|
|
133
|
+
const key = m[1];
|
|
134
|
+
return {
|
|
135
|
+
key,
|
|
136
|
+
label: `session-${key.toLowerCase()}`,
|
|
137
|
+
branch: `claim/${key.toUpperCase()}`,
|
|
138
|
+
launch: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const key = slug(raw.toLowerCase());
|
|
142
|
+
return { key, label: `session-${key}`, branch: `claim/${key}`, launch: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The deterministic on-disk path for a spec's session worktree. Same `slug(label)` convention
|
|
146
|
+
* as the engine's setupWorktree, so both lifecycles live side by side in ctx.worktreeRoot. */
|
|
147
|
+
export function worktreePathFor(ctx, specId) {
|
|
148
|
+
return join(ctx.worktreeRoot, slug(keyInfoFor(specId).label));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── owner marker I/O ─────────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function readMarker(path, d) {
|
|
154
|
+
try { return JSON.parse(d.fs.readFileSync(join(path, OWNER_FILE), "utf8")); }
|
|
155
|
+
catch { return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeMarker(path, marker, d) {
|
|
159
|
+
d.fs.writeFileSync(join(path, OWNER_FILE), JSON.stringify(marker, null, 2));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── allocate ─────────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Allocate (or re-attach to) the session worktree bound to `specId`.
|
|
166
|
+
*
|
|
167
|
+
* Idempotent: if the worktree already exists and its marker is for the same spec AND either the
|
|
168
|
+
* owner process is still alive or the sessionId matches, the existing worktree is returned with
|
|
169
|
+
* `created: false` — re-running `alloc` from the same session is a no-op, never a re-clone.
|
|
170
|
+
*
|
|
171
|
+
* → `{ path, branch, created, marker }`
|
|
172
|
+
*/
|
|
173
|
+
export function allocateForClaim(ctx, { specId, sessionId, agent, pid, baseRef } = {}, deps) {
|
|
174
|
+
const d = withDeps(deps);
|
|
175
|
+
if (!specId) throw new Error("allocateForClaim: specId is required");
|
|
176
|
+
const info = keyInfoFor(specId);
|
|
177
|
+
const path = worktreePathFor(ctx, specId);
|
|
178
|
+
|
|
179
|
+
// ── re-attach path ────────────────────────────────────────────────────────────────────────
|
|
180
|
+
const existing = d.fs.existsSync(path) ? readMarker(path, d) : null;
|
|
181
|
+
const ownerUnknownButUnreleased = existing != null && existing.pid == null && !existing.releasedAt;
|
|
182
|
+
if (existing && existing.specId === specId
|
|
183
|
+
&& (d.isPidAlive(existing.pid)
|
|
184
|
+
|| (sessionId && existing.sessionId === sessionId)
|
|
185
|
+
// Same doctrine as reap: an unknown owner (pid null) on an UNRELEASED marker is presumed
|
|
186
|
+
// LIVE. Tearing it down to re-create it would silently discard another session's work.
|
|
187
|
+
|| ownerUnknownButUnreleased)) {
|
|
188
|
+
return { path, branch: existing.branch || info.branch, created: false, marker: existing };
|
|
189
|
+
}
|
|
190
|
+
// A stale directory for the SAME key (dead owner, different session) is reclaimed links-first
|
|
191
|
+
// before we hand the path to `git worktree add`, which refuses a non-empty destination.
|
|
192
|
+
if (d.fs.existsSync(path)) {
|
|
193
|
+
teardownWorktree(ctx, path, true);
|
|
194
|
+
if (d.fs.existsSync(path)) { try { d.fs.rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── create path ───────────────────────────────────────────────────────────────────────────
|
|
198
|
+
d.fs.mkdirSync(ctx.worktreeRoot, { recursive: true });
|
|
199
|
+
const base = baseRef || "HEAD";
|
|
200
|
+
// The base is the CHECKED-OUT branch's HEAD, never `main`: the session must start from the
|
|
201
|
+
// code the agent is actually working on.
|
|
202
|
+
const baseSha = (d.git(ctx, ctx.repo, "rev-parse", base).stdout || "").trim() || null;
|
|
203
|
+
const baseBranch = (d.git(ctx, ctx.repo, "rev-parse", "--abbrev-ref", "HEAD").stdout || "").trim() || null;
|
|
204
|
+
|
|
205
|
+
const branchExists = d.git(ctx, ctx.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${info.branch}`).status === 0;
|
|
206
|
+
const add = branchExists
|
|
207
|
+
? d.git(ctx, ctx.repo, "worktree", "add", path, info.branch)
|
|
208
|
+
: d.git(ctx, ctx.repo, "worktree", "add", "-b", info.branch, path, base);
|
|
209
|
+
if (add.status !== 0) {
|
|
210
|
+
throw new Error(`git worktree add failed for ${specId}: ${String(add.stderr || add.stdout || "").trim()}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Junction node_modules exactly as setupWorktree does — root (hoisted toolchain; Node
|
|
214
|
+
// resolution walks up) and the workspace. Only what actually exists (HS-OM28: a sibling repo
|
|
215
|
+
// may have no spex-console workspace).
|
|
216
|
+
const rootNM = join(ctx.repo, "node_modules");
|
|
217
|
+
if (d.fs.existsSync(rootNM) && !d.fs.existsSync(join(path, "node_modules"))) makeLink(join(path, "node_modules"), rootNM);
|
|
218
|
+
const spexNM = join(ctx.repo, ctx.spex, "node_modules");
|
|
219
|
+
if (d.fs.existsSync(spexNM) && d.fs.existsSync(join(path, ctx.spex)) && !d.fs.existsSync(join(path, ctx.spex, "node_modules"))) {
|
|
220
|
+
makeLink(join(path, ctx.spex, "node_modules"), spexNM);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const marker = {
|
|
224
|
+
kind: "session",
|
|
225
|
+
specId,
|
|
226
|
+
sessionId: sessionId ?? null,
|
|
227
|
+
agent: agent ?? null,
|
|
228
|
+
// pid is OPTIONAL and defaults to null — NOT to this process's pid (HS-L4Q5 integration
|
|
229
|
+
// finding). On Windows the `hs spawn` process and the `wt.exe` launcher both exit seconds
|
|
230
|
+
// after handing off to the terminal, so a pid recorded here is dead almost immediately while
|
|
231
|
+
// the agent session inside the worktree is very much alive. A dead pid is positive evidence
|
|
232
|
+
// for reaping; recording the launcher's pid would therefore make `reap` delete live sessions.
|
|
233
|
+
// `null` means UNKNOWN, and liveness has to come from releasedAt / claimIsActive instead.
|
|
234
|
+
// Build-kind markers (engine setupWorktree) keep their real pid — that process IS the owner.
|
|
235
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
236
|
+
startedAt: d.now().toISOString(),
|
|
237
|
+
branch: info.branch,
|
|
238
|
+
baseSha,
|
|
239
|
+
baseBranch,
|
|
240
|
+
releasedAt: null,
|
|
241
|
+
};
|
|
242
|
+
writeMarker(path, marker, d);
|
|
243
|
+
return { path, branch: info.branch, created: true, marker };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── release ──────────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Mark the session worktree for `specId` released. NEVER deletes anything — release makes the
|
|
250
|
+
* worktree ELIGIBLE for reaping; `reapWorktrees` still refuses to remove it while it is dirty.
|
|
251
|
+
* → `{ found, alreadyReleased, marker }`
|
|
252
|
+
*/
|
|
253
|
+
export function markReleased(ctx, { specId } = {}, deps) {
|
|
254
|
+
const d = withDeps(deps);
|
|
255
|
+
if (!specId) throw new Error("markReleased: specId is required");
|
|
256
|
+
const path = worktreePathFor(ctx, specId);
|
|
257
|
+
const marker = d.fs.existsSync(path) ? readMarker(path, d) : null;
|
|
258
|
+
if (!marker) return { found: false, alreadyReleased: false, marker: null };
|
|
259
|
+
if (marker.releasedAt) return { found: true, alreadyReleased: true, marker };
|
|
260
|
+
const next = { ...marker, releasedAt: d.now().toISOString() };
|
|
261
|
+
writeMarker(path, next, d);
|
|
262
|
+
return { found: true, alreadyReleased: false, marker: next };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── rebind ───────────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Re-point an existing session worktree at a DIFFERENT spec — the `/hs-defer` shape, where the
|
|
269
|
+
* same live checkout continues under a split child id. The marker's specId and the branch NAME
|
|
270
|
+
* change; the DIRECTORY does not move (moving a live worktree out from under an editor, a dev
|
|
271
|
+
* server and git's own administrative pointer is exactly the class of breakage this epic exists
|
|
272
|
+
* to avoid). → `{ found, branch }`
|
|
273
|
+
*/
|
|
274
|
+
export function rebind(ctx, { fromSpecId, toSpecId } = {}, deps) {
|
|
275
|
+
const d = withDeps(deps);
|
|
276
|
+
if (!fromSpecId || !toSpecId) throw new Error("rebind: fromSpecId and toSpecId are required");
|
|
277
|
+
const path = worktreePathFor(ctx, fromSpecId);
|
|
278
|
+
const marker = d.fs.existsSync(path) ? readMarker(path, d) : null;
|
|
279
|
+
if (!marker) return { found: false, branch: null };
|
|
280
|
+
const nextBranch = keyInfoFor(toSpecId).branch;
|
|
281
|
+
const oldBranch = marker.branch || keyInfoFor(fromSpecId).branch;
|
|
282
|
+
if (oldBranch !== nextBranch) {
|
|
283
|
+
const mv = d.git(ctx, ctx.repo, "branch", "-m", oldBranch, nextBranch);
|
|
284
|
+
if (mv.status !== 0) {
|
|
285
|
+
throw new Error(`git branch -m ${oldBranch} ${nextBranch} failed: ${String(mv.stderr || mv.stdout || "").trim()}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
writeMarker(path, { ...marker, specId: toSpecId, branch: nextBranch }, d);
|
|
289
|
+
return { found: true, branch: nextBranch };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── list ─────────────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/** Parse `git worktree list --porcelain` into `[{ path, head, branch, detached, bare }]`. */
|
|
295
|
+
function parsePorcelainWorktrees(stdout) {
|
|
296
|
+
const out = [];
|
|
297
|
+
let cur = null;
|
|
298
|
+
for (const raw of String(stdout ?? "").split(/\r?\n/)) {
|
|
299
|
+
const line = raw.trim();
|
|
300
|
+
if (line.startsWith("worktree ")) {
|
|
301
|
+
if (cur) out.push(cur);
|
|
302
|
+
cur = { path: line.slice("worktree ".length), head: null, branch: null, detached: false, bare: false };
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!cur) continue;
|
|
306
|
+
if (line.startsWith("HEAD ")) cur.head = line.slice("HEAD ".length);
|
|
307
|
+
else if (line.startsWith("branch ")) cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
308
|
+
else if (line === "detached") cur.detached = true;
|
|
309
|
+
else if (line === "bare") cur.bare = true;
|
|
310
|
+
}
|
|
311
|
+
if (cur) out.push(cur);
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Dirty line count for a worktree — `git status --porcelain -uall`. `null` when the path is
|
|
316
|
+
* gone (a registration git has not pruned yet) or git refuses to run there. */
|
|
317
|
+
function dirtyCountFor(ctx, path, d) {
|
|
318
|
+
if (!d.fs.existsSync(path)) return null;
|
|
319
|
+
const r = d.git(ctx, path, "status", "--porcelain", "-uall");
|
|
320
|
+
if (r.status !== 0) return null;
|
|
321
|
+
return String(r.stdout ?? "").split(/\r?\n/)
|
|
322
|
+
.filter((l) => l.trim().length > 0)
|
|
323
|
+
// The OWNER MARKER never counts as the agent's work: the allocator writes
|
|
324
|
+
// .hsewt-owner.json into every worktree it creates, so counting it would make EVERY
|
|
325
|
+
// session worktree permanently "dirty" and therefore permanently unreapable — the
|
|
326
|
+
// bookkeeping file masquerading as somebody's unsaved changes.
|
|
327
|
+
.filter((l) => l.slice(3).trim().replace(/^"|"$/g, "").replace(/\\/g, "/") !== OWNER_FILE)
|
|
328
|
+
.length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Every worktree this repo knows about, joined with its owner marker.
|
|
333
|
+
*
|
|
334
|
+
* Two sources, because they disagree in exactly the cases that matter:
|
|
335
|
+
* - `git worktree list --porcelain` (from ctx.repo) — the REGISTRY. The main checkout is the
|
|
336
|
+
* first entry and is reported as `kind: "primary"`.
|
|
337
|
+
* - the directory entries under ctx.worktreeRoot — anything here that the registry does not
|
|
338
|
+
* know about is an UNREGISTERED leftover (`registered: false`), which is precisely what an
|
|
339
|
+
* interrupted run or a `git worktree prune` behind our back leaves behind.
|
|
340
|
+
*
|
|
341
|
+
* `kind` comes from the marker (`"session"` / `"build"`); a worktree with no usable marker is
|
|
342
|
+
* `"foreign"` — someone else's, and never reaped without an explicit opt-in.
|
|
343
|
+
*/
|
|
344
|
+
export function listWorktrees(ctx, deps) {
|
|
345
|
+
const d = withDeps(deps);
|
|
346
|
+
const nowMs = d.now().getTime();
|
|
347
|
+
const rows = [];
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
|
|
350
|
+
const push = (path, base, registered) => {
|
|
351
|
+
const marker = readMarker(path, d);
|
|
352
|
+
const isPrimary = samePath(path, ctx.repo);
|
|
353
|
+
const kind = isPrimary ? "primary" : (marker?.kind === "session" || marker?.kind === "build" ? marker.kind : "foreign");
|
|
354
|
+
const startedAt = marker?.startedAt ?? null;
|
|
355
|
+
const startedMs = startedAt ? Date.parse(startedAt) : NaN;
|
|
356
|
+
const pid = Number.isInteger(marker?.pid) ? marker.pid : null;
|
|
357
|
+
rows.push({
|
|
358
|
+
// resolve(): `git worktree list` reports FORWARD slashes on Windows, so a raw registry
|
|
359
|
+
// path would never string-compare equal to one a caller built with path.join().
|
|
360
|
+
path: resolve(path),
|
|
361
|
+
branch: base?.branch ?? marker?.branch ?? null,
|
|
362
|
+
head: base?.head ?? null,
|
|
363
|
+
kind,
|
|
364
|
+
specId: marker?.specId ?? null,
|
|
365
|
+
sessionId: marker?.sessionId ?? null,
|
|
366
|
+
pid,
|
|
367
|
+
// THREE-valued: true (alive), false (positively gone), null (UNKNOWN — the marker records
|
|
368
|
+
// no pid, which is the normal case for a session worktree whose launcher has exited).
|
|
369
|
+
// `null` must never be read as "dead"; see reapWorktrees.
|
|
370
|
+
pidAlive: pid == null ? null : d.isPidAlive(pid),
|
|
371
|
+
startedAt,
|
|
372
|
+
ageMs: Number.isFinite(startedMs) ? nowMs - startedMs : null,
|
|
373
|
+
releasedAt: marker?.releasedAt ?? null,
|
|
374
|
+
dirtyCount: dirtyCountFor(ctx, path, d),
|
|
375
|
+
registered,
|
|
376
|
+
});
|
|
377
|
+
seen.add(canonicalPath(path));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const listed = d.git(ctx, ctx.repo, "worktree", "list", "--porcelain");
|
|
381
|
+
if (listed.status === 0) {
|
|
382
|
+
for (const wt of parsePorcelainWorktrees(listed.stdout)) push(wt.path, wt, true);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (d.fs.existsSync(ctx.worktreeRoot)) {
|
|
386
|
+
for (const entry of d.fs.readdirSync(ctx.worktreeRoot)) {
|
|
387
|
+
const path = join(ctx.worktreeRoot, entry);
|
|
388
|
+
if (seen.has(canonicalPath(path))) continue;
|
|
389
|
+
let st; try { st = d.fs.lstatSync(path); } catch { continue; }
|
|
390
|
+
if (!st.isDirectory() || st.isSymbolicLink()) continue;
|
|
391
|
+
push(path, null, false);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return rows;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── claim liveness (the console probe) ───────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** Is this claim row still live? Same two-field test peer-presence.mjs applies when it builds the
|
|
400
|
+
* PEER AGENTS block — a row past its `staleAt` or `expiresAt` is not somebody working. */
|
|
401
|
+
function claimRowIsLive(row, nowMs) {
|
|
402
|
+
const staleAtMs = row?.staleAt ? Date.parse(row.staleAt) : null;
|
|
403
|
+
const expiresAtMs = row?.expiresAt ? Date.parse(row.expiresAt) : null;
|
|
404
|
+
if (staleAtMs != null && Number.isFinite(staleAtMs) && staleAtMs < nowMs) return false;
|
|
405
|
+
if (expiresAtMs != null && Number.isFinite(expiresAtMs) && expiresAtMs < nowMs) return false;
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A `claimIsActive(specId)` implementation backed by the console's `/api/claims`.
|
|
411
|
+
*
|
|
412
|
+
* → `true` a live claim for that spec exists · `false` the read SUCCEEDED and there is none ·
|
|
413
|
+
* `null` the read failed (no console URL, network error, non-OK response, bad payload). The
|
|
414
|
+
* three-way answer is the whole point: "the console says nobody holds this" is grounds to reclaim
|
|
415
|
+
* a worktree, "I could not ask" never is.
|
|
416
|
+
*
|
|
417
|
+
* ONE round trip for the lifetime of the checker: the claim list is fetched on the first call and
|
|
418
|
+
* every later spec id is answered from that array. `reapWorktrees` builds a fresh checker per
|
|
419
|
+
* call, so a reap over fifty worktrees still makes exactly one request.
|
|
420
|
+
*
|
|
421
|
+
* Ids are compared by FILE KEY via `keyInfoFor`, so a marker holding the full basename
|
|
422
|
+
* (`HS-L4Q5-03DO-TASK-…`), the store id (`HS-L4Q5`) and a bare `L4Q5` all match the same claim.
|
|
423
|
+
*
|
|
424
|
+
* The vendor modules are imported LAZILY: the controller imports this file on every build, and a
|
|
425
|
+
* console/auth module it never calls has no business being on that import path.
|
|
426
|
+
*/
|
|
427
|
+
export function createConsoleClaimChecker(ctx, {
|
|
428
|
+
fetchFn = globalThis.fetch, timeoutMs = CLAIM_PROBE_TIMEOUT_MS, consoleUrl, now = () => new Date(),
|
|
429
|
+
} = {}) {
|
|
430
|
+
let pending = null;
|
|
431
|
+
const loadClaims = () => {
|
|
432
|
+
if (!pending) {
|
|
433
|
+
pending = (async () => {
|
|
434
|
+
const url = consoleUrl ?? consoleUrlForWorktree(ctx);
|
|
435
|
+
if (!url || typeof fetchFn !== "function") return null;
|
|
436
|
+
const [{ readClaimsFromConsole }, { getAuthHeader }] = await Promise.all([
|
|
437
|
+
import("../../vendor/heyspex-mcp/peer-presence.mjs"),
|
|
438
|
+
import("../../vendor/heyspex-mcp/console-fetch.mjs"),
|
|
439
|
+
]);
|
|
440
|
+
// Same bearer every other console read sends (scripts/validate-no-foreign-session-sweep.mjs
|
|
441
|
+
// builds it identically) — a cloud console 401s an unauthenticated /api/claims, which
|
|
442
|
+
// would otherwise look like "no claims" instead of "could not check".
|
|
443
|
+
return await readClaimsFromConsole(url, fetchFn, timeoutMs, getAuthHeader());
|
|
444
|
+
})().catch(() => null);
|
|
445
|
+
}
|
|
446
|
+
return pending;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
return async function claimIsActive(specId) {
|
|
450
|
+
if (!specId) return null;
|
|
451
|
+
const claims = await loadClaims();
|
|
452
|
+
if (!Array.isArray(claims)) return null; // could not check — never "no claim"
|
|
453
|
+
const want = keyInfoFor(specId).key.toLowerCase();
|
|
454
|
+
const nowMs = now().getTime();
|
|
455
|
+
return claims.some((c) => keyInfoFor(String(c?.specId ?? "")).key.toLowerCase() === want
|
|
456
|
+
&& claimRowIsLive(c, nowMs));
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── reap ─────────────────────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Retire the `claim/<KEY>` branch a session worktree was sitting on, once that worktree is gone.
|
|
464
|
+
*
|
|
465
|
+
* ALWAYS lowercase `-d`, never `-D`. `-d` refuses a branch holding commits that are not merged
|
|
466
|
+
* anywhere else, and that refusal is the entire safety property: the worktree's files are already
|
|
467
|
+
* deleted by this point, so a force-delete would be the last step of destroying work nobody has
|
|
468
|
+
* reviewed. It equally refuses a branch checked out in another worktree — also surfaced verbatim
|
|
469
|
+
* rather than worked around.
|
|
470
|
+
*
|
|
471
|
+
* → `{ name, deleted: true }` · `{ name, deleted: false, reason }` where reason is "unmerged",
|
|
472
|
+
* "missing", or git's own first stderr line.
|
|
473
|
+
*/
|
|
474
|
+
function deleteClaimBranch(ctx, name, d) {
|
|
475
|
+
const r = d.git(ctx, ctx.repo, "branch", "-d", name);
|
|
476
|
+
if (r.status === 0) return { name, deleted: true };
|
|
477
|
+
const firstLine = String(r.stderr || r.stdout || "").trim().split(/\r?\n/)[0] || "git branch -d failed";
|
|
478
|
+
let reason = firstLine;
|
|
479
|
+
if (/not fully merged/i.test(firstLine)) reason = "unmerged";
|
|
480
|
+
else if (/not found|didn't match|does not exist/i.test(firstLine)) reason = "missing";
|
|
481
|
+
return { name, deleted: false, reason };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Reclaim worktrees that are finished with. Removal needs BOTH halves:
|
|
486
|
+
*
|
|
487
|
+
* ELIGIBLE, which depends on what kind of worktree it is —
|
|
488
|
+
* · foreign — only ever with `includeForeign && yes`; the operator's explicit say-so IS the
|
|
489
|
+
* evidence, because a worktree with no marker tells us nothing else.
|
|
490
|
+
* · launch-* — an UNCLAIMED placeholder (a window opened before any spec was claimed). Its
|
|
491
|
+
* `pid` is meaningless and there is no claim to ask about, so it ages out:
|
|
492
|
+
* `releasedAt` set OR at least `ttlMs` old (LAUNCH_PLACEHOLDER_TTL_MS). A
|
|
493
|
+
* window opened ten minutes ago is never touched; one abandoned yesterday is,
|
|
494
|
+
* and `ttlMs: 0` (`--ttl-hours 0`) ages every placeholder out at once.
|
|
495
|
+
* · otherwise — `releasedAt` set, OR the console says the claim is NOT active, OR the owner
|
|
496
|
+
* process is POSITIVELY gone (`pidAlive === false`).
|
|
497
|
+
* and CLEAN — `dirtyCount === 0`.
|
|
498
|
+
*
|
|
499
|
+
* `pidAlive === null` (UNKNOWN — the normal state of a session marker, whose launcher process
|
|
500
|
+
* exits seconds after the agent starts) is NOT evidence of death and never makes a worktree
|
|
501
|
+
* eligible on its own; it is reported as `owner_unknown`. Liveness must then come from
|
|
502
|
+
* `releasedAt` or from `claimIsActive`. This is the difference between housekeeping and deleting
|
|
503
|
+
* a colleague's uncommitted afternoon.
|
|
504
|
+
*
|
|
505
|
+
* The dirty check is the load-bearing one: every other signal is a heuristic about a process,
|
|
506
|
+
* but uncommitted work is unrecoverable once the worktree is gone.
|
|
507
|
+
*
|
|
508
|
+
* `dryRun` performs ZERO writes — no teardown, and no branch deletion either.
|
|
509
|
+
* The primary checkout is never a candidate, under any flags.
|
|
510
|
+
*
|
|
511
|
+
* → `{ dryRun, removed: [{ path, branch }], skipped: [{ path, reason }] }`, where `branch` is
|
|
512
|
+
* `null` (a dry run, or a non-session worktree with no branch of ours) or
|
|
513
|
+
* `{ name, deleted: true }` / `{ name, deleted: false, reason }`.
|
|
514
|
+
* Reasons: "primary" · "foreign" · "claim_active" · "owner_alive" · "owner_unknown" ·
|
|
515
|
+
* "placeholder_fresh" · "dirty".
|
|
516
|
+
*/
|
|
517
|
+
export async function reapWorktrees(ctx, {
|
|
518
|
+
dryRun = false, includeForeign = false, yes = false, ttlMs = LAUNCH_PLACEHOLDER_TTL_MS,
|
|
519
|
+
} = {}, deps) {
|
|
520
|
+
const d = withDeps(deps);
|
|
521
|
+
const removed = [];
|
|
522
|
+
const skipped = [];
|
|
523
|
+
// ONE checker per reap call → one console round trip, whatever the worktree count.
|
|
524
|
+
const claimIsActive = typeof d.claimIsActive === "function"
|
|
525
|
+
? d.claimIsActive
|
|
526
|
+
: createConsoleClaimChecker(ctx, { fetchFn: d.fetch, consoleUrl: d.consoleUrl, now: d.now });
|
|
527
|
+
|
|
528
|
+
for (const wt of listWorktrees(ctx, d)) {
|
|
529
|
+
if (wt.kind === "primary") { skipped.push({ path: wt.path, reason: "primary" }); continue; }
|
|
530
|
+
if (wt.kind === "foreign" && !(includeForeign && yes)) { skipped.push({ path: wt.path, reason: "foreign" }); continue; }
|
|
531
|
+
|
|
532
|
+
let claimActive = null;
|
|
533
|
+
if (wt.specId) {
|
|
534
|
+
try { claimActive = await claimIsActive(wt.specId); } catch { claimActive = null; }
|
|
535
|
+
}
|
|
536
|
+
if (claimActive === true) { skipped.push({ path: wt.path, reason: "claim_active" }); continue; }
|
|
537
|
+
|
|
538
|
+
const isPlaceholder = Boolean(wt.specId) && keyInfoFor(wt.specId).launch;
|
|
539
|
+
let eligible, reason;
|
|
540
|
+
if (wt.kind === "foreign") {
|
|
541
|
+
eligible = true; // gated above on includeForeign && yes
|
|
542
|
+
} else if (isPlaceholder) {
|
|
543
|
+
// `>=`, not `>`. `--ttl-hours 0` means "age it out immediately", and a placeholder
|
|
544
|
+
// allocated in the same millisecond as the reap has `ageMs === 0`. Under a strict `>`
|
|
545
|
+
// that worktree read as FRESH, so a zero TTL silently kept it AND the dirty gate below
|
|
546
|
+
// was never reached — a dirty aged-out placeholder came back "placeholder_fresh"
|
|
547
|
+
// instead of "dirty", which is a refusal for the wrong reason (right answer, wrong
|
|
548
|
+
// evidence: a dirty-detection regression would have been invisible). At the real 24h
|
|
549
|
+
// TTL the two comparisons differ by exactly one millisecond and nothing else.
|
|
550
|
+
eligible = Boolean(wt.releasedAt) || (wt.ageMs != null && wt.ageMs >= ttlMs);
|
|
551
|
+
reason = "placeholder_fresh";
|
|
552
|
+
} else {
|
|
553
|
+
eligible = Boolean(wt.releasedAt) || claimActive === false || wt.pidAlive === false;
|
|
554
|
+
reason = wt.pidAlive === null ? "owner_unknown" : "owner_alive";
|
|
555
|
+
}
|
|
556
|
+
if (!eligible) { skipped.push({ path: wt.path, reason }); continue; }
|
|
557
|
+
|
|
558
|
+
if ((wt.dirtyCount ?? 0) > 0) { skipped.push({ path: wt.path, reason: "dirty" }); continue; }
|
|
559
|
+
|
|
560
|
+
if (dryRun) { removed.push({ path: wt.path, branch: null }); continue; }
|
|
561
|
+
|
|
562
|
+
// Links-first teardown, always — never a recursive delete through a live junction.
|
|
563
|
+
const clean = teardownWorktree(ctx, wt.path, true);
|
|
564
|
+
if (d.fs.existsSync(wt.path)) {
|
|
565
|
+
if (clean || !d.fs.existsSync(join(wt.path, "node_modules"))) {
|
|
566
|
+
try { d.fs.rmSync(wt.path, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (d.fs.existsSync(wt.path)) { skipped.push({ path: wt.path, reason: "dirty" }); continue; }
|
|
570
|
+
|
|
571
|
+
// The worktree is gone; retire its branch too. Removing the checkout but leaving
|
|
572
|
+
// `claim/<KEY>` behind is how a repo silently accumulates dozens of dead refs. Only SESSION
|
|
573
|
+
// worktrees have one — build worktrees are cut `--detach`, and a foreign worktree's branch
|
|
574
|
+
// is not ours to touch.
|
|
575
|
+
const branch = wt.kind === "session" && wt.branch ? deleteClaimBranch(ctx, wt.branch, d) : null;
|
|
576
|
+
removed.push({ path: wt.path, branch });
|
|
577
|
+
}
|
|
578
|
+
return { dryRun: Boolean(dryRun), removed, skipped };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── console URL ──────────────────────────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The console URL a session worktree should talk to — deliberately the PRIMARY checkout's, not
|
|
585
|
+
* a per-worktree one: a worktree is an isolated *file* tree, not an isolated control plane, and
|
|
586
|
+
* every claim/progress/release call must land on the same console the human is looking at.
|
|
587
|
+
*/
|
|
588
|
+
export function consoleUrlForWorktree(ctx) {
|
|
589
|
+
try {
|
|
590
|
+
const url = resolveConsoleUrl({ repoRoot: ctx.repo });
|
|
591
|
+
if (url) return url;
|
|
592
|
+
} catch { /* fall through to the repo discovery file */ }
|
|
593
|
+
try {
|
|
594
|
+
const discovery = readFileSync(join(ctx.repo, ".heyspex", "console-url"), "utf8").trim();
|
|
595
|
+
if (discovery) return discovery.replace(/\/+$/, "");
|
|
596
|
+
} catch { /* nothing to fall back to */ }
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
const USAGE = `usage: node dev-factory/lib/worktree-allocator.mjs <list|alloc|release|reap|rebind> [options]
|
|
603
|
+
|
|
604
|
+
list every worktree (registry + unregistered leftovers) with its marker
|
|
605
|
+
alloc --spec ID allocate/re-attach the claim-bound worktree for a spec
|
|
606
|
+
release --spec ID mark that worktree released (never deletes)
|
|
607
|
+
reap reclaim released/dead/claim-inactive worktrees that are CLEAN
|
|
608
|
+
rebind --spec ID --to ID re-point a worktree (and rename its branch) at another spec
|
|
609
|
+
|
|
610
|
+
options:
|
|
611
|
+
--spec ID the spec id (required for alloc/release/rebind)
|
|
612
|
+
--to ID the new spec id (rebind)
|
|
613
|
+
--session ID session id recorded in the owner marker
|
|
614
|
+
--agent NAME agent name recorded in the owner marker
|
|
615
|
+
--pid N owner pid recorded in the owner marker (default: NONE — a session's
|
|
616
|
+
launcher process exits immediately, so a recorded pid would read as dead)
|
|
617
|
+
--ttl-hours N how long an unclaimed launch-* placeholder may idle before reap takes it
|
|
618
|
+
(default 24)
|
|
619
|
+
--root DIR worktree root (default: %USERPROFILE%/.hsewt)
|
|
620
|
+
--repo DIR repo root (default: this checkout)
|
|
621
|
+
--json print ONE JSON object instead of human lines
|
|
622
|
+
--dry-run compute the answer, write nothing
|
|
623
|
+
--include-foreign let reap consider worktrees with no owner marker (requires --yes)
|
|
624
|
+
--yes confirm a destructive reap
|
|
625
|
+
`;
|
|
626
|
+
|
|
627
|
+
const FLAGS_WITH_VALUE = new Set(["--spec", "--to", "--session", "--agent", "--pid", "--root", "--repo", "--ttl-hours"]);
|
|
628
|
+
const BOOL_FLAGS = new Set(["--json", "--dry-run", "--include-foreign", "--yes"]);
|
|
629
|
+
const VERBS = new Set(["list", "alloc", "release", "reap", "rebind"]);
|
|
630
|
+
|
|
631
|
+
export function parseCliArgs(argv) {
|
|
632
|
+
const out = { verb: null, json: false, dryRun: false, includeForeign: false, yes: false };
|
|
633
|
+
let i = 0;
|
|
634
|
+
if (argv[0] && !argv[0].startsWith("-")) { out.verb = argv[0]; i = 1; }
|
|
635
|
+
for (; i < argv.length; i++) {
|
|
636
|
+
const a = argv[i];
|
|
637
|
+
if (BOOL_FLAGS.has(a)) {
|
|
638
|
+
if (a === "--json") out.json = true;
|
|
639
|
+
else if (a === "--dry-run") out.dryRun = true;
|
|
640
|
+
else if (a === "--include-foreign") out.includeForeign = true;
|
|
641
|
+
else out.yes = true;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (FLAGS_WITH_VALUE.has(a)) {
|
|
645
|
+
const v = argv[++i];
|
|
646
|
+
if (v === undefined) return { error: `missing value for ${a}`, code: EXIT_INVALID_ARG };
|
|
647
|
+
if (a === "--spec") out.spec = v;
|
|
648
|
+
else if (a === "--to") out.to = v;
|
|
649
|
+
else if (a === "--session") out.session = v;
|
|
650
|
+
else if (a === "--agent") out.agent = v;
|
|
651
|
+
else if (a === "--pid") {
|
|
652
|
+
const n = Number.parseInt(v, 10);
|
|
653
|
+
if (!Number.isInteger(n) || n <= 0) return { error: `bad --pid ${v}`, code: EXIT_INVALID_ARG };
|
|
654
|
+
out.pid = n;
|
|
655
|
+
} else if (a === "--ttl-hours") {
|
|
656
|
+
const n = Number.parseFloat(v);
|
|
657
|
+
if (!Number.isFinite(n) || n < 0) return { error: `bad --ttl-hours ${v}`, code: EXIT_INVALID_ARG };
|
|
658
|
+
out.ttlMs = Math.round(n * 60 * 60 * 1000);
|
|
659
|
+
} else if (a === "--root") out.root = v;
|
|
660
|
+
else out.repo = v;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
return { error: `unknown flag ${a}`, code: EXIT_USAGE };
|
|
664
|
+
}
|
|
665
|
+
if (!out.verb || !VERBS.has(out.verb)) return { error: `unknown verb ${out.verb ?? "(none)"}`, code: EXIT_USAGE };
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function runCli(argv) {
|
|
670
|
+
const args = parseCliArgs(argv);
|
|
671
|
+
if (args.error) { process.stderr.write(`${args.error}\n\n${USAGE}`); return args.code; }
|
|
672
|
+
|
|
673
|
+
const ctx = makeCtx({ repo: args.repo || undefined, worktreeRoot: args.root || undefined });
|
|
674
|
+
const emit = (obj, lines) => {
|
|
675
|
+
if (args.json) process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
|
|
676
|
+
else for (const l of lines) process.stdout.write(`${l}\n`);
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (args.verb === "list") {
|
|
680
|
+
const worktrees = listWorktrees(ctx);
|
|
681
|
+
emit({ worktrees }, worktrees.map((w) =>
|
|
682
|
+
`${w.kind.padEnd(8)} ${String(w.branch ?? "(detached)").padEnd(28)} dirty=${w.dirtyCount ?? "?"} `
|
|
683
|
+
+ `${w.registered ? "" : "UNREGISTERED "}${w.specId ?? ""}${w.releasedAt ? " released" : ""} ${w.path}`));
|
|
684
|
+
return EXIT_OK;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (args.verb === "alloc") {
|
|
688
|
+
if (!args.spec) { process.stderr.write(`alloc requires --spec\n`); return EXIT_INVALID_ARG; }
|
|
689
|
+
const info = keyInfoFor(args.spec);
|
|
690
|
+
if (args.dryRun) {
|
|
691
|
+
const baseSha = (git(ctx, ctx.repo, "rev-parse", "HEAD").stdout || "").trim() || null;
|
|
692
|
+
emit({ dryRun: true, path: worktreePathFor(ctx, args.spec), branch: info.branch, baseSha },
|
|
693
|
+
[`would allocate ${worktreePathFor(ctx, args.spec)} on ${info.branch} from ${baseSha}`]);
|
|
694
|
+
return EXIT_OK;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const r = allocateForClaim(ctx, {
|
|
698
|
+
specId: args.spec, sessionId: args.session, agent: args.agent, pid: args.pid,
|
|
699
|
+
});
|
|
700
|
+
emit(r, [`${r.created ? "allocated" : "re-attached"} ${r.path} on ${r.branch} `
|
|
701
|
+
+ `(port ${devServerPortFor(info.key)})`]);
|
|
702
|
+
return EXIT_OK;
|
|
703
|
+
} catch (e) { process.stderr.write(`${e.message}\n`); return EXIT_WRITE_FAILED; }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (args.verb === "release") {
|
|
707
|
+
if (!args.spec) { process.stderr.write(`release requires --spec\n`); return EXIT_INVALID_ARG; }
|
|
708
|
+
const r = markReleased(ctx, { specId: args.spec });
|
|
709
|
+
emit(r, [r.found
|
|
710
|
+
? `${r.alreadyReleased ? "already released" : "released"} ${worktreePathFor(ctx, args.spec)}`
|
|
711
|
+
: `no worktree for ${args.spec}`]);
|
|
712
|
+
return r.found ? EXIT_OK : EXIT_CHECK_FAILED;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (args.verb === "rebind") {
|
|
716
|
+
if (!args.spec || !args.to) { process.stderr.write(`rebind requires --spec and --to\n`); return EXIT_INVALID_ARG; }
|
|
717
|
+
try {
|
|
718
|
+
const r = rebind(ctx, { fromSpecId: args.spec, toSpecId: args.to });
|
|
719
|
+
emit(r, [r.found ? `rebound ${args.spec} → ${args.to} on ${r.branch}` : `no worktree for ${args.spec}`]);
|
|
720
|
+
return r.found ? EXIT_OK : EXIT_CHECK_FAILED;
|
|
721
|
+
} catch (e) { process.stderr.write(`${e.message}\n`); return EXIT_WRITE_FAILED; }
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// reap
|
|
725
|
+
const r = await reapWorktrees(ctx, {
|
|
726
|
+
dryRun: args.dryRun, includeForeign: args.includeForeign, yes: args.yes,
|
|
727
|
+
...(args.ttlMs != null ? { ttlMs: args.ttlMs } : {}),
|
|
728
|
+
});
|
|
729
|
+
emit(r, [
|
|
730
|
+
`${r.dryRun ? "would remove" : "removed"} ${r.removed.length}:`,
|
|
731
|
+
...r.removed.map((x) => ` ${x.path}${x.branch
|
|
732
|
+
? ` — branch ${x.branch.name} ${x.branch.deleted ? "deleted" : `KEPT (${x.branch.reason})`}`
|
|
733
|
+
: ""}`),
|
|
734
|
+
...(r.removed.length ? [] : [" (none)"]),
|
|
735
|
+
...r.skipped.map((s) => ` skipped ${s.path} — ${s.reason}`),
|
|
736
|
+
]);
|
|
737
|
+
return EXIT_OK;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Guard on MAIN-entry (not just on a flag) so importing this module from the controller never
|
|
741
|
+
// fires the CLI — the same convention engine/worktree.mjs uses for its --self-test.
|
|
742
|
+
if (import.meta.url.endsWith(`/${basename(process.argv[1] ?? "")}`)) {
|
|
743
|
+
runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
744
|
+
}
|