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,740 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dev-factory/engine/worktree.mjs
|
|
3
|
+
// ENGINE — per-task git worktree isolation + deterministic gate.
|
|
4
|
+
// Task: HS-OBJC-W6AD-TASK-per-task-git-worktree-isolation-baseline (epic HS-UB1R).
|
|
5
|
+
//
|
|
6
|
+
// Lifted from the proven dev-factory/phase2-loop.mjs so the loop controller (controller.mjs) can run each
|
|
7
|
+
// task's build + gate inside an ISOLATED worktree: the gate sees ONLY that task's diff, the live tree / dev
|
|
8
|
+
// server / Neon DB are never touched, and the net-new tsc/test baseline is captured in the clean tree.
|
|
9
|
+
//
|
|
10
|
+
// Windows-safe (the hard-won lessons):
|
|
11
|
+
// - node_modules is JUNCTIONED to the monorepo root (hoisted toolchain — Node resolution walks up) AND the
|
|
12
|
+
// workspace — never copied.
|
|
13
|
+
// - git runs with core.longpaths=true so deep specs/ filenames don't trip MAX_PATH (260).
|
|
14
|
+
// - teardown removes the junction LINKS first, THEN `git worktree remove` — NEVER recurse through a junction
|
|
15
|
+
// (that would delete the real node_modules).
|
|
16
|
+
//
|
|
17
|
+
// API:
|
|
18
|
+
// const ctx = makeCtx({ repo?, spex?, worktreeRoot? });
|
|
19
|
+
// const wt = setupWorktree(ctx, label); // → abs worktree path (node_modules junctioned)
|
|
20
|
+
// const base = baselineTsc(ctx, wt); // → { count } net-new-error baseline (clean tree)
|
|
21
|
+
// const tbase = baselineTest(ctx, wt, testRel); // → { pass } RED-BASELINE pre-check (HS-AY5K):
|
|
22
|
+
// // runs testRel (auto-routed node:test/vitest) against
|
|
23
|
+
// // the UNMODIFIED tree — pass:false means the gate can
|
|
24
|
+
// // never go green regardless of the candidate code.
|
|
25
|
+
// const res = runGate(ctx, wt, { target, test }, code, base); // target/test REPO-ROOT-relative; write→tsc(≤baseline)→
|
|
26
|
+
// // test(auto-routed node:test / plain self-test script / vitest)→restore
|
|
27
|
+
// teardownWorktree(ctx, wt); // links-first, then git worktree remove
|
|
28
|
+
//
|
|
29
|
+
// Self-test (NO models — proves the isolation plumbing, the deterministic gate's foundation):
|
|
30
|
+
// node dev-factory/engine/worktree.mjs --self-test
|
|
31
|
+
|
|
32
|
+
import { symlinkSync, existsSync, lstatSync, rmdirSync, rmSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { join, resolve, dirname } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
|
|
40
|
+
export const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
41
|
+
|
|
42
|
+
// Liveness marker written into every worktree at setup. The startup sweep uses it to tell an
|
|
43
|
+
// ACTIVE concurrent worktree (owner process still alive → leave it) from a STRANDED one an
|
|
44
|
+
// interrupted run left behind (owner dead, or no marker at all → safe to reclaim). Without this,
|
|
45
|
+
// a blanket sweep would delete a sibling build's live worktree.
|
|
46
|
+
export const OWNER_FILE = ".hsewt-owner.json";
|
|
47
|
+
|
|
48
|
+
// Is a process still running? process.kill(pid, 0) sends no signal — it throws ESRCH if the pid
|
|
49
|
+
// is gone, and succeeds (or throws EPERM, which still means "exists") if it is alive. Works on win32.
|
|
50
|
+
export function isPidAlive(pid) {
|
|
51
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
52
|
+
try { process.kill(pid, 0); return true; }
|
|
53
|
+
catch (e) { return e.code === "EPERM"; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the context the worktree functions thread through. All paths absolute. */
|
|
57
|
+
export function makeCtx(opts = {}) {
|
|
58
|
+
const repo = resolve(opts.repo || join(__dirname, "..", ".."));
|
|
59
|
+
const spex = opts.spex || "spex-console";
|
|
60
|
+
const worktreeRoot = resolve(opts.worktreeRoot
|
|
61
|
+
|| (process.env.USERPROFILE ? join(process.env.USERPROFILE, ".hsewt") : join(repo, ".hsewt")));
|
|
62
|
+
return {
|
|
63
|
+
repo, spex, worktreeRoot,
|
|
64
|
+
tscTimeout: opts.tscTimeout ?? 180000,
|
|
65
|
+
testTimeout: opts.testTimeout ?? 120000,
|
|
66
|
+
gitTimeout: opts.gitTimeout ?? 180000,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function makeLink(link, target) {
|
|
71
|
+
symlinkSync(resolve(target), link, process.platform === "win32" ? "junction" : "dir");
|
|
72
|
+
}
|
|
73
|
+
// Remove ONLY the junction/symlink reparse point — never recurse into (and delete) the real target.
|
|
74
|
+
function removeLink(link) {
|
|
75
|
+
if (!existsSync(link)) return true;
|
|
76
|
+
try {
|
|
77
|
+
const st = lstatSync(link);
|
|
78
|
+
if (st.isSymbolicLink() || process.platform === "win32") { rmdirSync(link); return !existsSync(link); }
|
|
79
|
+
rmSync(link, { force: true }); return !existsSync(link);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error(` ⚠ could NOT remove link ${link}: ${e.message} — leaving worktree in place (refusing to risk node_modules).`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** git with core.longpaths=true so a full checkout's deep filenames don't trip Windows MAX_PATH. */
|
|
87
|
+
export function git(ctx, cwd, ...a) {
|
|
88
|
+
return spawnSync("git", ["-c", "core.longpaths=true", ...a], { cwd, encoding: "utf8", timeout: ctx.gitTimeout });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Count `tsc --noEmit` errors in <worktree>/<spex>. Returns { count, status, output, timedOut }. */
|
|
92
|
+
export function runTsc(ctx, worktree) {
|
|
93
|
+
const r = spawnSync("npx --no-install tsc --noEmit -p tsconfig.json", {
|
|
94
|
+
cwd: join(worktree, ctx.spex), shell: true, encoding: "utf8", timeout: ctx.tscTimeout,
|
|
95
|
+
});
|
|
96
|
+
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
97
|
+
return { count: (out.match(/error TS\d+/g) || []).length, status: r.status, output: out.trim().slice(0, 1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run a scoped vitest file in <worktree>/<spex>. Returns { pass, output, timedOut }. */
|
|
101
|
+
export function runVitest(ctx, worktree, testRel) {
|
|
102
|
+
const r = spawnSync(`npx --no-install vitest run ${testRel}`, {
|
|
103
|
+
cwd: join(worktree, ctx.spex), shell: true, encoding: "utf8", timeout: ctx.testTimeout,
|
|
104
|
+
});
|
|
105
|
+
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
106
|
+
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// HS-AY5K: the deterministic gate used to run EVERY test file through scoped
|
|
110
|
+
// vitest, no matter what test framework it was actually written against. Every
|
|
111
|
+
// vendor/heyspex-mcp/*.test.mjs imports describe/it/test from node:test — under vitest
|
|
112
|
+
// they either execute on import and register with nothing ("no test suite found") or,
|
|
113
|
+
// once vitest.config's `include` glob excludes the path, never even get picked up ("no
|
|
114
|
+
// test files found") — a permanently red gate for code that is fully green under the
|
|
115
|
+
// correct runner (`node --test`, 18/18). Detect the runner from the test file's own
|
|
116
|
+
// imports (cheap — one regex over the source, no execution) and dispatch to the correct
|
|
117
|
+
// one instead of hardcoding vitest.
|
|
118
|
+
const NODE_TEST_IMPORT_RE = /\bfrom\s+['"]node:test['"]|\brequire\(\s*['"]node:test['"]\s*\)/;
|
|
119
|
+
|
|
120
|
+
/** Does this test file import node:test (vs vitest/jest/etc.)? `testAbsPath` is read
|
|
121
|
+
* directly off disk. Never throws — an unreadable/missing file is simply "not
|
|
122
|
+
* node:test" and falls through to the existing vitest path (the pre-HS-XCCE-DIAG
|
|
123
|
+
* default), so a caller still using the old spex-relative test-path convention (which
|
|
124
|
+
* resolves to nothing under this REPO-ROOT-relative check) degrades safely rather than
|
|
125
|
+
* misrouting. */
|
|
126
|
+
export function isNodeTestFile(testAbsPath) {
|
|
127
|
+
try {
|
|
128
|
+
return NODE_TEST_IMPORT_RE.test(readFileSync(testAbsPath, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Run a node:test file via `node --test <file>`. `testRel` is REPO-ROOT-relative
|
|
135
|
+
* (same convention as `task.target`, HS-W2F6) and resolved against the WORKTREE
|
|
136
|
+
* ROOT — not under ctx.spex, since vendor/ and other non-spex-console directories have
|
|
137
|
+
* no spex-console project to run inside, and `node --test` needs no project at all.
|
|
138
|
+
* Mirrors runVitest's { pass, output, timedOut } contract so runGate/baselineTest can
|
|
139
|
+
* dispatch to either interchangeably. */
|
|
140
|
+
export function runNodeTest(ctx, worktree, testRel) {
|
|
141
|
+
const abs = join(worktree, testRel);
|
|
142
|
+
const r = spawnSync(`node --test "${abs}"`, {
|
|
143
|
+
cwd: worktree, shell: true, encoding: "utf8", timeout: ctx.testTimeout,
|
|
144
|
+
});
|
|
145
|
+
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
146
|
+
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// HS-AY5K edge (c): a THIRD gate convention alongside node:test and vitest — a
|
|
150
|
+
// plain script with NO test framework at all, verified by running it and checking its
|
|
151
|
+
// raw exit code (0 = green, non-zero = red). Two shapes exist in this repo today:
|
|
152
|
+
// 1. Inline argv guard: `if (process.argv.includes("--self-test")) { ...; process.exit(ok?0:1); }`
|
|
153
|
+
// — the SAME convention controller.mjs's `detectSelfTestGate` already detects for
|
|
154
|
+
// dev-factory/*.mjs TARGETS (Diagram 5a); this mirrors that regex so `--test`
|
|
155
|
+
// arguments pointing at such a file route the same way.
|
|
156
|
+
// 2. Companion `<name>.selftest.mjs` file: running the file directly (no flag) IS the
|
|
157
|
+
// self-test — e.g. scripts/validate-vault-ids.selftest.mjs. Before this branch
|
|
158
|
+
// existed, a `--test` pointing at either shape fell through to vitest, which found
|
|
159
|
+
// no describe/it and reported a permanently red gate unrelated to the candidate.
|
|
160
|
+
const SELF_TEST_ARGV_GUARD_RE = /(?:process\.)?argv\.includes\(\s*["']--self-test["']\s*\)/;
|
|
161
|
+
const SELF_TEST_COMPANION_FILE_RE = /\.selftest\.[cm]?js$/;
|
|
162
|
+
|
|
163
|
+
/** Does `testAbsPath` verify via a plain exit-code self-test (no node:test/vitest
|
|
164
|
+
* import)? Never throws — mirrors {@link isNodeTestFile}'s safe-degrade contract. */
|
|
165
|
+
export function isPlainSelfTestScript(testAbsPath) {
|
|
166
|
+
if (SELF_TEST_COMPANION_FILE_RE.test(testAbsPath)) return true;
|
|
167
|
+
try {
|
|
168
|
+
return SELF_TEST_ARGV_GUARD_RE.test(readFileSync(testAbsPath, "utf8"));
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Run a plain exit-code self-test script. `testRel` is REPO-ROOT-relative (same
|
|
175
|
+
* convention as {@link runNodeTest} — these scripts live outside spex-console, e.g.
|
|
176
|
+
* scripts/ or dev-factory/, and need no project to run inside). The companion-file
|
|
177
|
+
* convention (`*.selftest.mjs`) runs bare — invoking the file IS the check; the
|
|
178
|
+
* inline-argv-guard convention needs `--self-test` appended so the guarded branch
|
|
179
|
+
* actually fires. Mirrors runNodeTest/runVitest's { pass, output, timedOut } contract. */
|
|
180
|
+
export function runPlainSelfTestScript(ctx, worktree, testRel) {
|
|
181
|
+
const abs = join(worktree, testRel);
|
|
182
|
+
const needsFlag = !SELF_TEST_COMPANION_FILE_RE.test(abs);
|
|
183
|
+
const r = spawnSync(`node "${abs}"${needsFlag ? " --self-test" : ""}`, {
|
|
184
|
+
cwd: worktree, shell: true, encoding: "utf8", timeout: ctx.testTimeout,
|
|
185
|
+
});
|
|
186
|
+
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
187
|
+
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Dispatch to the correct runner for `testRel` (auto-detected) — the single seam
|
|
191
|
+
* runGate and baselineTest both call through, so the routing rule lives in exactly
|
|
192
|
+
* one place. Order matters: node:test and the plain-self-test conventions are both
|
|
193
|
+
* narrow, cheap-to-check positives; vitest is the default fallback for everything else. */
|
|
194
|
+
function runScopedTest(ctx, worktree, testRel) {
|
|
195
|
+
const abs = join(worktree, testRel);
|
|
196
|
+
if (isNodeTestFile(abs)) return runNodeTest(ctx, worktree, testRel);
|
|
197
|
+
if (isPlainSelfTestScript(abs)) return runPlainSelfTestScript(ctx, worktree, testRel);
|
|
198
|
+
return runVitest(ctx, worktree, testRel);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Capture the net-new-error baseline in the CLEAN worktree (before any task code is written). */
|
|
202
|
+
export function baselineTsc(ctx, worktree) {
|
|
203
|
+
const t = runTsc(ctx, worktree);
|
|
204
|
+
return { count: t.count, timedOut: t.timedOut };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// HS-2HK7: the deterministic gate only knew the spex-console TS toolchain (tsc +
|
|
208
|
+
// scoped vitest) or a .mjs self-test — a companion C# task (dotnet build + dotnet
|
|
209
|
+
// test) had no fleet-gateable path and fell out of the free local build seat entirely.
|
|
210
|
+
// A LANGUAGE-PROFILE gate lets a caller (controller.mjs, keyed off `--gate-build-cmd`/
|
|
211
|
+
// `--gate-test-cmd`, or a per-task checks manifest) supply its own build/test commands
|
|
212
|
+
// instead of the hardcoded pair — same worktree isolation, same write→run→restore
|
|
213
|
+
// contract as `runGate`.
|
|
214
|
+
|
|
215
|
+
/** Run an arbitrary shell command in `cwd`. Same `{ pass, output, timedOut }` shape as
|
|
216
|
+
* runVitest/runNodeTest, so a language-profile command slots into the same dispatch
|
|
217
|
+
* contract as the TS toolchain. */
|
|
218
|
+
export function runShellCmd(cmd, cwd, timeoutMs) {
|
|
219
|
+
const r = spawnSync(cmd, { cwd, shell: true, encoding: "utf8", timeout: timeoutMs });
|
|
220
|
+
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
221
|
+
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Baseline for a language-profile gate: run `buildCmd` (and `testCmd`, if given)
|
|
225
|
+
* against the UNMODIFIED worktree. Mirrors baselineTsc/baselineTest's "red-before-any-
|
|
226
|
+
* candidate" contract — a command already red on the clean tree is a gate/environment
|
|
227
|
+
* fault, not a code defect. `cwdRel` is worktree-root-relative (e.g. "companion" for a
|
|
228
|
+
* .sln that lives at `<repo>/companion/*.sln`); omit for the worktree root. */
|
|
229
|
+
export function baselineLanguageGate(ctx, worktree, { buildCmd, testCmd, cwdRel = "" } = {}) {
|
|
230
|
+
if (!buildCmd) return { pass: true, output: "", timedOut: false };
|
|
231
|
+
const cwd = cwdRel ? join(worktree, cwdRel) : worktree;
|
|
232
|
+
const b = runShellCmd(buildCmd, cwd, ctx.tscTimeout);
|
|
233
|
+
if (!b.pass || b.timedOut) return b;
|
|
234
|
+
if (!testCmd) return { pass: true, output: "", timedOut: false };
|
|
235
|
+
return runShellCmd(testCmd, cwd, ctx.testTimeout);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run a LANGUAGE-PROFILE gate instead of the hardcoded tsc+vitest pair (HS-2HK7) —
|
|
239
|
+
* e.g. a companion C# task: `buildCmd: "dotnet build companion/HeySpex.Companion.sln"`,
|
|
240
|
+
* `testCmd: "dotnet test companion/HeySpex.Companion.sln"`. Returns
|
|
241
|
+
* `{ pass, tscOk, testOk, output }` — the SAME field NAMES `runGate` returns (`tscOk`/
|
|
242
|
+
* `testOk` repurposed here as build-ok/test-ok) so a caller's existing best-of-N
|
|
243
|
+
* scoring (`errorCount` derived from tscOk/testOk) works unmodified for either gate.
|
|
244
|
+
* ALWAYS restores the target via `git checkout`, same as `runGate`. */
|
|
245
|
+
export function runLanguageGate(ctx, worktree, task, code, { buildCmd, testCmd, cwdRel = "" } = {}) {
|
|
246
|
+
const abs = join(worktree, task.target);
|
|
247
|
+
let result = { pass: false, tscOk: false, testOk: false, output: "" };
|
|
248
|
+
try {
|
|
249
|
+
// HS-HECA: a genuinely new file's parent dir may exist neither at HEAD nor in the
|
|
250
|
+
// dirty working tree the overlay copied from — writeFileSync then threw ENOENT
|
|
251
|
+
// before the build/test command ever ran, reporting a gate/environment fault for
|
|
252
|
+
// work the builder had actually produced. Mirrors overlayIntoWorktree's own
|
|
253
|
+
// mkdirSync(dirname(to), { recursive: true }) before its writeFileSync.
|
|
254
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
255
|
+
writeFileSync(abs, code);
|
|
256
|
+
const cwd = cwdRel ? join(worktree, cwdRel) : worktree;
|
|
257
|
+
const b = runShellCmd(buildCmd, cwd, ctx.tscTimeout);
|
|
258
|
+
const tscOk = b.pass && !b.timedOut;
|
|
259
|
+
let testOk = false, testOut = "";
|
|
260
|
+
if (tscOk && testCmd) { const t = runShellCmd(testCmd, cwd, ctx.testTimeout); testOk = t.pass && !t.timedOut; testOut = t.output; }
|
|
261
|
+
else if (tscOk) testOk = true; // build-only gate when no test command is specified
|
|
262
|
+
result = {
|
|
263
|
+
pass: tscOk && testOk, tscOk, testOk,
|
|
264
|
+
output: (tscOk ? "" : `BUILD (${buildCmd}):\n${b.output}\n`)
|
|
265
|
+
+ (tscOk && testCmd ? `TEST (${testCmd}):\n${testOut}` : "").slice(0, 1000),
|
|
266
|
+
};
|
|
267
|
+
} catch (e) {
|
|
268
|
+
// Environmental (bad path, missing dotnet SDK, permissions) — NOT a candidate defect.
|
|
269
|
+
// Mirrors runGate's gateException contract (HS-W2F6).
|
|
270
|
+
result = { pass: false, tscOk: false, testOk: false, gateException: true, output: `gate exception: ${e.message}` };
|
|
271
|
+
} finally {
|
|
272
|
+
git(ctx, worktree, "checkout", "--", task.target);
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Capture the TEST HALF of the gate's baseline (HS-AY5K): run the correctly-routed
|
|
278
|
+
* test (auto-detected node:test vs vitest) against the UNMODIFIED target in the clean
|
|
279
|
+
* worktree, before any candidate code is written. If this is already red, no candidate
|
|
280
|
+
* can ever pass the gate — the fault is the runner/environment, not the generated code
|
|
281
|
+
* — so the caller should abort before spending any best-of-N attempts on it. Mirrors
|
|
282
|
+
* baselineTsc's pattern. `testRel` falsy → nothing to baseline, trivially green (a
|
|
283
|
+
* tsc-only gate has no test half). */
|
|
284
|
+
// HS-AY5K edge (a): a NEW-FILE task's test doesn't exist yet at baseline — the
|
|
285
|
+
// builder is EXPECTED to create it alongside the target. Without this check, the
|
|
286
|
+
// missing file made runScopedTest's underlying runner report "red" (file not found /
|
|
287
|
+
// no test files found), which the caller then wrongly treated as a genuine
|
|
288
|
+
// gate/environment fault and aborted before the builder ever ran. Check BOTH path
|
|
289
|
+
// conventions runScopedTest's runners use (node:test resolves testRel repo-root-
|
|
290
|
+
// relative against `worktree`; vitest resolves it spex-relative against
|
|
291
|
+
// `worktree/ctx.spex`) — a NEW-FILE test could land under either once the builder
|
|
292
|
+
// creates it.
|
|
293
|
+
function testExistsAtBaseline(ctx, worktree, testRel) {
|
|
294
|
+
return existsSync(join(worktree, testRel)) || existsSync(join(worktree, ctx.spex, testRel));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function baselineTest(ctx, worktree, testRel) {
|
|
298
|
+
if (!testRel) return { pass: true, output: "", timedOut: false };
|
|
299
|
+
if (!testExistsAtBaseline(ctx, worktree, testRel)) {
|
|
300
|
+
return {
|
|
301
|
+
pass: true, notFound: true, timedOut: false,
|
|
302
|
+
output: `test file does not exist at baseline (${testRel}) — expected NEW-FILE condition, not a gate/environment fault`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return runScopedTest(ctx, worktree, testRel);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// HS-L9BX: `setupWorktree` builds the gate's worktree from `git worktree add --detach
|
|
309
|
+
// <path> HEAD` — committed content only. A wave-N+1 task importing a wave-N module that
|
|
310
|
+
// is still `review_pending`/untracked (agents don't commit; the human reviewer commits
|
|
311
|
+
// on Pass) is structurally invisible to the worktree: TS2307 fires even though the
|
|
312
|
+
// builder's code is correct and the file exists in the main tree. Detect exactly that
|
|
313
|
+
// shape and let the caller classify it as `gate_environment_fault` instead of spending
|
|
314
|
+
// the retry/replan budget "fixing" code that already imports a real file correctly.
|
|
315
|
+
const TS2307_RE = /^(.+?)\(\d+,\d+\): error TS2307: Cannot find module '([^']+)'/gm;
|
|
316
|
+
const RELATIVE_IMPORT_EXTS = [".ts", ".tsx", ".mts", ".cts", ".d.ts", "/index.ts", "/index.tsx", ""];
|
|
317
|
+
|
|
318
|
+
/** Resolve a relative import specifier from `fromFileAbs` to a file on disk, trying the
|
|
319
|
+
* extensions TS allows an extensionless relative specifier to resolve to. Returns the
|
|
320
|
+
* absolute path if found, else null. Never throws. */
|
|
321
|
+
function resolveRelativeImport(fromFileAbs, specifier) {
|
|
322
|
+
const base = resolve(dirname(fromFileAbs), specifier);
|
|
323
|
+
for (const ext of RELATIVE_IMPORT_EXTS) {
|
|
324
|
+
const candidate = base + ext;
|
|
325
|
+
try { if (existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Does `tscOutput` contain a TS2307 on a RELATIVE import (bare/package specifiers are a
|
|
331
|
+
* different bug class) that fails to resolve inside `worktree` but DOES resolve to a
|
|
332
|
+
* real file at the same relative location in `ctx.repo` (the main tree)? Returns
|
|
333
|
+
* `{ specifier, relMissing }` on a match, else `null`. `tscOutput` is tsc's raw stdout+
|
|
334
|
+
* stderr, cwd `<worktree>/<ctx.spex>` (see `runTsc`), so reported file paths are
|
|
335
|
+
* relative to that cwd. */
|
|
336
|
+
function detectMissingWorktreeDependency(ctx, worktree, tscOutput) {
|
|
337
|
+
const tscCwd = join(worktree, ctx.spex);
|
|
338
|
+
let m;
|
|
339
|
+
TS2307_RE.lastIndex = 0;
|
|
340
|
+
while ((m = TS2307_RE.exec(tscOutput))) {
|
|
341
|
+
const [, filePart, specifier] = m;
|
|
342
|
+
if (!specifier.startsWith(".")) continue; // bare/package specifiers: not this bug
|
|
343
|
+
const fileAbsInWorktree = resolve(tscCwd, filePart.trim());
|
|
344
|
+
if (resolveRelativeImport(fileAbsInWorktree, specifier)) continue; // genuinely resolves — not this bug
|
|
345
|
+
const relFromWorktreeRoot = fileAbsInWorktree.slice(worktree.length).replace(/^[\\/]/, "");
|
|
346
|
+
const fileAbsInMain = join(ctx.repo, relFromWorktreeRoot);
|
|
347
|
+
const foundInMain = resolveRelativeImport(fileAbsInMain, specifier);
|
|
348
|
+
if (foundInMain) {
|
|
349
|
+
return { specifier, relMissing: foundInMain.slice(ctx.repo.length).replace(/^[\\/]/, "") };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The deterministic gate on a worktree: write code → tsc (no NEW errors vs baseline) AND scoped vitest → restore.
|
|
356
|
+
// Returns { pass, tscOk, testOk, output }. ALWAYS restores the target via `git checkout` so the tree stays pristine.
|
|
357
|
+
//
|
|
358
|
+
// `task.target` is REPO-ROOT-relative (e.g. "spex-console/src/x.ts" OR a repo-root path
|
|
359
|
+
// like "schemas/foo.json") and is resolved against the WORKTREE ROOT. It used to be joined
|
|
360
|
+
// under ctx.spex, which fabricated a non-existent path for any target outside spex-console/
|
|
361
|
+
// — the gate then threw an ENOENT before tsc/vitest ran, and the caller mistook that
|
|
362
|
+
// environmental failure for a red gate (HS-W2F6). tsc/vitest still run in ctx.spex (the
|
|
363
|
+
// only TS/test project); a repo-root edit leaves the spex-console baseline untouched, so a
|
|
364
|
+
// tsc-only gate on such a target correctly passes.
|
|
365
|
+
export function runGate(ctx, worktree, task, code, baseline) {
|
|
366
|
+
const abs = join(worktree, task.target);
|
|
367
|
+
let result = { pass: false, tscOk: false, testOk: false, output: "" };
|
|
368
|
+
try {
|
|
369
|
+
// HS-HECA: same greenfield gap as runLanguageGate — a brand-new file in a
|
|
370
|
+
// brand-new subdirectory has no parent dir at HEAD or in the overlay, so
|
|
371
|
+
// writeFileSync ENOENTs before tsc/vitest ever runs. Create it first.
|
|
372
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
373
|
+
writeFileSync(abs, code);
|
|
374
|
+
const tsc = runTsc(ctx, worktree);
|
|
375
|
+
const tscOk = !tsc.timedOut && tsc.count <= baseline;
|
|
376
|
+
if (!tscOk && !tsc.timedOut) {
|
|
377
|
+
const missing = detectMissingWorktreeDependency(ctx, worktree, tsc.output);
|
|
378
|
+
if (missing) {
|
|
379
|
+
result = {
|
|
380
|
+
pass: false, tscOk: false, testOk: false, gateException: true,
|
|
381
|
+
output: `gate environment fault (HS-L9BX): worktree is missing '${missing.relMissing}' — imported via '${missing.specifier}'. The file exists in the main tree but the gate worktree is built from HEAD (committed content only), so an untracked wave-N dependency is structurally invisible to it. Commit the dependency's output, or overlay it into the worktree before gating — not a defect in this candidate's code.`,
|
|
382
|
+
};
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
let testOk = false, testOut = "";
|
|
387
|
+
if (tscOk && task.test) { const v = runScopedTest(ctx, worktree, task.test); testOk = v.pass; testOut = v.output; }
|
|
388
|
+
else if (tscOk) testOk = true; // tsc-only gate when no test is specified
|
|
389
|
+
result = {
|
|
390
|
+
pass: tscOk && testOk, tscOk, testOk,
|
|
391
|
+
output: (tscOk ? "" : `TSC (${tsc.count} err vs baseline ${baseline})${tsc.timedOut ? " [TIMEOUT]" : ""}:\n${tsc.output}\n`)
|
|
392
|
+
+ (tscOk && task.test ? `VITEST:\n${testOut}` : "").slice(0, 1000),
|
|
393
|
+
};
|
|
394
|
+
} catch (e) {
|
|
395
|
+
// An exception here is ENVIRONMENTAL (bad path, permissions, missing toolchain) — NOT a
|
|
396
|
+
// defect in the candidate code. `gateException: true` lets the caller escalate it as
|
|
397
|
+
// such instead of self-repairing it or burning no-progress iterations on it (HS-W2F6).
|
|
398
|
+
result = { pass: false, tscOk: false, testOk: false, gateException: true, output: `gate exception: ${e.message}` };
|
|
399
|
+
} finally {
|
|
400
|
+
git(ctx, worktree, "checkout", "--", task.target);
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Reclaim STRANDED worktrees an interrupted run left in ctx.worktreeRoot — links-first, so a
|
|
407
|
+
* later recursive delete can never follow a live junction into the real node_modules (root cause
|
|
408
|
+
* of HS-QSQX). Skips any worktree whose owner process is still alive (an active concurrent
|
|
409
|
+
* build). A worktree with no owner marker is treated as stranded (legacy / pre-marker strays).
|
|
410
|
+
* Returns { swept: string[], skipped: string[] }.
|
|
411
|
+
*/
|
|
412
|
+
export function sweepStaleWorktrees(ctx, { quiet = false } = {}) {
|
|
413
|
+
const swept = [], skipped = [];
|
|
414
|
+
if (!existsSync(ctx.worktreeRoot)) return { swept, skipped };
|
|
415
|
+
for (const entry of readdirSync(ctx.worktreeRoot)) {
|
|
416
|
+
const path = join(ctx.worktreeRoot, entry);
|
|
417
|
+
let st; try { st = lstatSync(path); } catch { continue; }
|
|
418
|
+
if (!st.isDirectory() || st.isSymbolicLink()) continue;
|
|
419
|
+
// Owner still alive? Leave the live worktree untouched.
|
|
420
|
+
let owner = null;
|
|
421
|
+
try { owner = JSON.parse(readFileSync(join(path, OWNER_FILE), "utf8")); } catch { /* no/invalid marker → stranded */ }
|
|
422
|
+
if (owner && isPidAlive(owner.pid)) { skipped.push(entry); continue; }
|
|
423
|
+
// HS-L4Q5: claim-bound SESSION worktrees are owned by the allocator (dev-factory/lib/
|
|
424
|
+
// worktree-allocator.mjs) and are reaped ONLY by reapWorktrees, which additionally checks
|
|
425
|
+
// releasedAt / claim liveness / dirtiness. A dead-pid session worktree still holds a human
|
|
426
|
+
// agent's uncommitted work, so the build-side sweep must never reclaim it.
|
|
427
|
+
if (owner && owner.kind === "session") { skipped.push(entry); continue; }
|
|
428
|
+
// teardownWorktree removes the junction LINKS first and refuses if any survives — never a raw
|
|
429
|
+
// recursive delete. For strays git can't recognise it then prunes; sweep the leftover plain
|
|
430
|
+
// (now junction-free) checkout tree.
|
|
431
|
+
const clean = teardownWorktree(ctx, path, quiet);
|
|
432
|
+
if (existsSync(path)) {
|
|
433
|
+
// Only reachable once both known junctions are confirmed gone (teardownWorktree refuses
|
|
434
|
+
// otherwise), so the remaining tree is ordinary files — safe to remove recursively.
|
|
435
|
+
if (clean || !existsSync(join(path, "node_modules"))) { try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
436
|
+
}
|
|
437
|
+
if (!existsSync(path)) swept.push(entry);
|
|
438
|
+
else if (!quiet) console.error(` ⚠ sweep left ${path} in place (a node_modules link survived) — inspect manually.`);
|
|
439
|
+
}
|
|
440
|
+
if (!quiet && (swept.length || skipped.length)) {
|
|
441
|
+
console.log(` worktree sweep: reclaimed ${swept.length} stranded [${swept.join(", ")}]${skipped.length ? `, left ${skipped.length} live [${skipped.join(", ")}]` : ""}`);
|
|
442
|
+
}
|
|
443
|
+
return { swept, skipped };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Create a fresh detached worktree with node_modules junctioned (root + workspace). → abs path. */
|
|
447
|
+
export function setupWorktree(ctx, label) {
|
|
448
|
+
const path = join(ctx.worktreeRoot, slug(label));
|
|
449
|
+
mkdirSync(ctx.worktreeRoot, { recursive: true });
|
|
450
|
+
// Reclaim any stranded worktrees (dead owners) BEFORE `git worktree add` — the prevention for
|
|
451
|
+
// HS-QSQX: an interrupted prior run must never leave a live junction for a later delete to
|
|
452
|
+
// follow. Live sibling worktrees (owner alive) are preserved.
|
|
453
|
+
sweepStaleWorktrees(ctx, { quiet: true });
|
|
454
|
+
if (existsSync(path)) teardownWorktree(ctx, path, true);
|
|
455
|
+
const add = git(ctx, ctx.repo, "worktree", "add", "--detach", path, "HEAD");
|
|
456
|
+
if (add.status !== 0) throw new Error(`git worktree add failed: ${(add.stderr || add.stdout || "").trim()}`);
|
|
457
|
+
// HS-OM28 multi-repo builds: ctx.repo may now be a SIBLING checkout (e.g. heyspex-com),
|
|
458
|
+
// which has its own root node_modules but no spex-console workspace. Junction only what
|
|
459
|
+
// actually exists — the missing-workspace symlink used to throw ENOENT and take the whole
|
|
460
|
+
// worktree setup down to critic-only fidelity. Vault behavior is unchanged (both exist).
|
|
461
|
+
const rootNM = join(ctx.repo, "node_modules");
|
|
462
|
+
if (existsSync(rootNM)) makeLink(join(path, "node_modules"), rootNM);
|
|
463
|
+
const spexNM = join(ctx.repo, ctx.spex, "node_modules");
|
|
464
|
+
if (existsSync(spexNM) && existsSync(join(path, ctx.spex))) makeLink(join(path, ctx.spex, "node_modules"), spexNM);
|
|
465
|
+
writeFileSync(join(path, OWNER_FILE), JSON.stringify({ kind: "build", pid: process.pid, startedAt: new Date().toISOString(), label }));
|
|
466
|
+
return path;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// HS-6NFD follow-on (the HS-L9BX fix, not just its detector): `setupWorktree` builds the
|
|
470
|
+
// gate tree from `git worktree add --detach HEAD` — COMMITTED CONTENT ONLY. Agents don't
|
|
471
|
+
// commit (the human reviewer commits on Pass), so a wave-N+1 task whose dependency is a
|
|
472
|
+
// wave-N task sitting at in_review/review_pending compiles against a HEAD where that
|
|
473
|
+
// dependency DOES NOT EXIST. Proven live on epic HS-4UU2: HEAD's GatherOutcome.cs contains
|
|
474
|
+
// zero occurrences of `GatherRegion` while the working tree contains 8, so every wave-2
|
|
475
|
+
// extractor candidate would fail with CS0246 and burn its whole retry+replan budget on code
|
|
476
|
+
// that is in fact correct. HS-L9BX detects exactly this for TypeScript (TS2307) and can only
|
|
477
|
+
// REPORT it ("commit the dependency's output, or overlay it into the worktree") — this is
|
|
478
|
+
// that overlay.
|
|
479
|
+
//
|
|
480
|
+
// Deliberately NARROW: only paths the caller passes (the task's own DECLARED context files),
|
|
481
|
+
// and only when the main tree's content actually differs from the worktree's. Everything else
|
|
482
|
+
// stays HEAD-pure, so the gate still sees essentially one task's diff. Every overlaid file is
|
|
483
|
+
// returned so the caller can log it and put it in the run record — this weakens isolation, so
|
|
484
|
+
// it must never be silent.
|
|
485
|
+
|
|
486
|
+
/** Every DIRTY (modified or untracked) path in the main tree under `subdirRel`, repo-root-
|
|
487
|
+
* relative with forward slashes. Uses `git status --porcelain`, so .gitignore'd build output
|
|
488
|
+
* (bin/, obj/, node_modules/) is excluded for free. Deleted paths are omitted — there is
|
|
489
|
+
* nothing to overlay. Returns [] on any git failure (the caller degrades to declared-only).
|
|
490
|
+
*
|
|
491
|
+
* Why a whole subtree and not just declared dependencies (HS-6NFD, proven on HS-YUGJ): a
|
|
492
|
+
* declared-only overlay is TRANSITIVELY INCOMPLETE. HS-YUGJ declared GatherEngine.cs, so it
|
|
493
|
+
* was overlaid — but the working-tree GatherEngine.cs references `ICrawlGate`, declared in
|
|
494
|
+
* PolitenessGate.cs, which is ALSO uncommitted and which HS-YUGJ never declared. The baseline
|
|
495
|
+
* went red with CS0246 on code that is perfectly correct in the real tree. Chasing that
|
|
496
|
+
* closure per-language is a compiler's job, not a gate's; overlaying the project's dirty set
|
|
497
|
+
* is the honest answer, because "HEAD" is not a state this repo is ever actually in — agents
|
|
498
|
+
* never commit, the human commits on review Pass. */
|
|
499
|
+
export function dirtyPathsUnder(ctx, subdirRel = "") {
|
|
500
|
+
// `-uall` is load-bearing: plain --porcelain COLLAPSES a fully-untracked directory into one
|
|
501
|
+
// entry with a trailing slash ("companion/src/.../Voice/"). Overlaying that path hits EISDIR
|
|
502
|
+
// and silently drops the entire subsystem — which is exactly how the HS-YUGJ baseline still
|
|
503
|
+
// failed on WorkspaceWatcher/GhostPredictor after the first overlay fix. -uall enumerates the
|
|
504
|
+
// individual files instead.
|
|
505
|
+
const r = git(ctx, ctx.repo, "status", "--porcelain", "-uall", "--", subdirRel || ".");
|
|
506
|
+
if (r.status !== 0) return [];
|
|
507
|
+
const out = [];
|
|
508
|
+
for (const line of String(r.stdout ?? "").split(/\r?\n/)) {
|
|
509
|
+
if (!line.trim()) continue;
|
|
510
|
+
const code = line.slice(0, 2);
|
|
511
|
+
if (code.includes("D")) continue; // deleted — nothing to copy
|
|
512
|
+
let p = line.slice(3).trim();
|
|
513
|
+
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1); // git quotes odd paths
|
|
514
|
+
const arrow = p.indexOf(" -> "); // renames: take the destination
|
|
515
|
+
if (arrow !== -1) p = p.slice(arrow + 4);
|
|
516
|
+
if (p) out.push(p.replace(/\\/g, "/"));
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Copy the MAIN-TREE working content of `relPaths` (repo-root-relative) into `worktree`,
|
|
522
|
+
* but only where it differs from what the worktree already has. Returns
|
|
523
|
+
* `{ overlaid: [{path, bytes}], unchanged: string[], missing: string[] }`. Never throws —
|
|
524
|
+
* an unreadable/unwritable path lands in `missing` and the gate proceeds without it. */
|
|
525
|
+
export function overlayIntoWorktree(ctx, worktree, relPaths = []) {
|
|
526
|
+
const overlaid = [], unchanged = [], missing = [];
|
|
527
|
+
for (const rel of relPaths) {
|
|
528
|
+
const from = join(ctx.repo, rel);
|
|
529
|
+
const to = join(worktree, rel);
|
|
530
|
+
try {
|
|
531
|
+
if (!existsSync(from)) { missing.push(rel); continue; }
|
|
532
|
+
// Defensive: a caller-supplied path may still name a directory (a hand-authored
|
|
533
|
+
// `context_files:` entry, or a git output shape we did not anticipate). Skip it
|
|
534
|
+
// explicitly instead of throwing EISDIR into `missing`, where it reads like a real fault.
|
|
535
|
+
if (lstatSync(from).isDirectory()) { unchanged.push(rel); continue; }
|
|
536
|
+
const src = readFileSync(from, "utf8");
|
|
537
|
+
let dst = null;
|
|
538
|
+
try { dst = readFileSync(to, "utf8"); } catch { /* absent in the worktree — a NEW dependency */ }
|
|
539
|
+
if (dst === src) { unchanged.push(rel); continue; }
|
|
540
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
541
|
+
writeFileSync(to, src);
|
|
542
|
+
overlaid.push({ path: rel, bytes: src.length, reason: dst == null ? "absent at HEAD" : "differs from HEAD" });
|
|
543
|
+
} catch (e) {
|
|
544
|
+
missing.push(`${rel} (${e.message})`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { overlaid, unchanged, missing };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Teardown: remove junction LINKS first, THEN git worktree remove. Returns true on a clean teardown. */
|
|
551
|
+
export function teardownWorktree(ctx, path, quiet = false) {
|
|
552
|
+
const a = removeLink(join(path, ctx.spex, "node_modules"));
|
|
553
|
+
const b = removeLink(join(path, "node_modules"));
|
|
554
|
+
if (!a || !b) { if (!quiet) console.error(` ⚠ refusing to git-remove ${path} — a node_modules link survived.`); return false; }
|
|
555
|
+
const rm = git(ctx, ctx.repo, "worktree", "remove", "--force", path);
|
|
556
|
+
if (rm.status !== 0) git(ctx, ctx.repo, "worktree", "prune");
|
|
557
|
+
return !existsSync(path);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── self-test (no models): prove the isolation plumbing — the deterministic gate's foundation ─────────
|
|
561
|
+
// Guard on MAIN-entry too (not just the flag) so importing this module from the loop
|
|
562
|
+
// controller doesn't fire the self-test when the controller is run with --self-test.
|
|
563
|
+
if (process.argv.includes("--self-test") && import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""))) {
|
|
564
|
+
const ctx = makeCtx();
|
|
565
|
+
let ok = true;
|
|
566
|
+
const check = (name, cond) => { console.log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
|
|
567
|
+
console.log("== engine/worktree.mjs self-test ==");
|
|
568
|
+
console.log(`repo=${ctx.repo}\nspex=${ctx.spex}\nworktreeRoot=${ctx.worktreeRoot}\n`);
|
|
569
|
+
let wt;
|
|
570
|
+
try {
|
|
571
|
+
wt = setupWorktree(ctx, "selftest");
|
|
572
|
+
check("worktree dir created", existsSync(wt));
|
|
573
|
+
check("root node_modules junctioned", existsSync(join(wt, "node_modules")));
|
|
574
|
+
check("workspace node_modules junctioned", existsSync(join(wt, ctx.spex, "node_modules")));
|
|
575
|
+
const base = baselineTsc(ctx, wt);
|
|
576
|
+
check(`baseline tsc runs through the junctioned toolchain (count=${base.count}${base.timedOut ? ", TIMEOUT" : ""})`, !base.timedOut && typeof base.count === "number");
|
|
577
|
+
|
|
578
|
+
// HS-W2F6 regression: a REPO-ROOT target (a dir that exists at repo root but NOT
|
|
579
|
+
// under spex-console/, e.g. dev-factory/) must resolve to <worktree>/<target>. The old
|
|
580
|
+
// join-under-ctx.spex fabricated <worktree>/spex-console/dev-factory/… whose parent does
|
|
581
|
+
// not exist → ENOENT gate exception before tsc/vitest ran. A tsc-only gate on a repo-root
|
|
582
|
+
// JSON edit leaves the spex-console baseline untouched, so it must PASS (no gate exception).
|
|
583
|
+
const probe = { target: "dev-factory/.hsewt-gate-probe.json", test: null };
|
|
584
|
+
const probeAbs = join(wt, probe.target);
|
|
585
|
+
const g = runGate(ctx, wt, probe, "{}\n", base.count);
|
|
586
|
+
check("repo-root target resolves under the worktree root (no gate exception)", !g.gateException);
|
|
587
|
+
check("repo-root target gate ran tsc-only and passed (HS-JZBI path fixed)", g.pass === true && g.tscOk === true);
|
|
588
|
+
try { if (existsSync(probeAbs)) rmSync(probeAbs, { force: true }); } catch { /* best-effort cleanup of the untracked probe */ }
|
|
589
|
+
|
|
590
|
+
// HS-HECA regression: a target in a brand-new subdirectory (absent at HEAD AND in
|
|
591
|
+
// the dirty working tree — the overlay never creates it) used to ENOENT before the
|
|
592
|
+
// gate ever ran, misreporting a real builder output as a gate/environment fault.
|
|
593
|
+
// `mkdirSync(dirname(abs), { recursive: true })` in both runGate and runLanguageGate
|
|
594
|
+
// must create the whole missing chain (two nested new dirs, not just one level).
|
|
595
|
+
const greenfieldRoot = "dev-factory/.hsewt-greenfield-probe";
|
|
596
|
+
const greenfieldTarget = `${greenfieldRoot}/nested/probe.json`;
|
|
597
|
+
check("greenfield probe precondition: neither dir exists yet", !existsSync(join(wt, greenfieldRoot)));
|
|
598
|
+
try {
|
|
599
|
+
const greenfieldGate = runGate(ctx, wt, { target: greenfieldTarget, test: null }, "{}\n", base.count);
|
|
600
|
+
check("new file in a brand-new nested subdirectory reaches a real verdict (no gate exception / ENOENT)", !greenfieldGate.gateException);
|
|
601
|
+
check("new file in a brand-new nested subdirectory gate ran tsc-only and passed", greenfieldGate.pass === true && greenfieldGate.tscOk === true);
|
|
602
|
+
} finally {
|
|
603
|
+
try { if (existsSync(join(wt, greenfieldRoot))) rmSync(join(wt, greenfieldRoot), { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// HS-AY5K regression: the gate must DETECT node:test vs vitest from the test
|
|
607
|
+
// file's own imports and dispatch to the correct runner — vitest reports every
|
|
608
|
+
// vendor/heyspex-mcp/*.test.mjs FAILED (they import describe/it/test from node:test,
|
|
609
|
+
// not vitest) even though the same file is 18/18 green under `node --test`. Proven
|
|
610
|
+
// end-to-end against the REAL fixture that motivated the diagnosis (unmodified
|
|
611
|
+
// content — this proves runner ROUTING, not the builder).
|
|
612
|
+
const mcpTarget = "vendor/heyspex-mcp/capture-console-client.mjs";
|
|
613
|
+
const mcpTest = "vendor/heyspex-mcp/capture-console-client.test.mjs";
|
|
614
|
+
check("isNodeTestFile detects the real vendor/heyspex-mcp node:test fixture", isNodeTestFile(join(wt, mcpTest)) === true);
|
|
615
|
+
const vitestProbePath = join(tmpdir(), `hsewt-vitest-probe-${process.pid}.test.ts`);
|
|
616
|
+
writeFileSync(vitestProbePath, "import { describe, it, expect } from \"vitest\";\ndescribe(\"probe\", () => { it(\"passes\", () => { expect(1).toBe(1); }); });\n");
|
|
617
|
+
check("isNodeTestFile returns false for a vitest-authored file (not a blanket route-everything-to-node--test)", isNodeTestFile(vitestProbePath) === false);
|
|
618
|
+
try { rmSync(vitestProbePath, { force: true }); } catch { /* best-effort */ }
|
|
619
|
+
|
|
620
|
+
const mcpOriginal = readFileSync(join(wt, mcpTarget), "utf8");
|
|
621
|
+
const testBaseGreen = baselineTest(ctx, wt, mcpTest);
|
|
622
|
+
check("baselineTest: GREEN on the unmodified vendor/heyspex-mcp fixture (pass=true)", testBaseGreen.pass === true);
|
|
623
|
+
const mcpGate = runGate(ctx, wt, { target: mcpTarget, test: mcpTest }, mcpOriginal, base.count);
|
|
624
|
+
check("node:test file routed to `node --test` → GREEN gate on unmodified vendor/heyspex-mcp code (HS-AY5K)",
|
|
625
|
+
mcpGate.pass === true && mcpGate.tscOk === true && mcpGate.testOk === true);
|
|
626
|
+
|
|
627
|
+
// A vitest-authored fixture must still route through vitest (detection is not a
|
|
628
|
+
// blanket switch to node --test for every test file). `target` stays REPO-ROOT-
|
|
629
|
+
// relative (runGate's own convention, HS-W2F6); `test` stays SPEX-RELATIVE —
|
|
630
|
+
// the pre-existing runVitest contract (cwd = <worktree>/<spex>) that every real
|
|
631
|
+
// spex-console --test caller already relies on. The two conventions differing is
|
|
632
|
+
// exactly why a repo-root-relative test path safely falls through to "not found →
|
|
633
|
+
// not node:test → vitest" instead of misrouting: see isNodeTestFile's doc comment.
|
|
634
|
+
const vitestFixtureTargetRel = "spex-console/src/__hsewt_selftest_vitest_fixture__.test.ts";
|
|
635
|
+
const vitestFixtureTestRel = "src/__hsewt_selftest_vitest_fixture__.test.ts";
|
|
636
|
+
const vitestFixtureAbs = join(wt, vitestFixtureTargetRel);
|
|
637
|
+
const vitestFixtureContent = "import { describe, it, expect } from \"vitest\";\n\ndescribe(\"hsewt selftest vitest fixture\", () => {\n it(\"passes\", () => {\n expect(1 + 1).toBe(2);\n });\n});\n";
|
|
638
|
+
writeFileSync(vitestFixtureAbs, vitestFixtureContent);
|
|
639
|
+
try {
|
|
640
|
+
const vitestGate = runGate(ctx, wt, { target: vitestFixtureTargetRel, test: vitestFixtureTestRel }, vitestFixtureContent, base.count);
|
|
641
|
+
check("vitest-authored file is still routed to vitest (green gate)", vitestGate.pass === true && vitestGate.tscOk === true && vitestGate.testOk === true);
|
|
642
|
+
} finally {
|
|
643
|
+
try { if (existsSync(vitestFixtureAbs)) rmSync(vitestFixtureAbs, { force: true }); } catch { /* best-effort */ }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Red-baseline pre-check (HS-AY5K AC2/AC3): a test that is red BEFORE any
|
|
647
|
+
// candidate code is written must be reported red by baselineTest, so the caller can
|
|
648
|
+
// abort before spending any best-of-N attempts on a fault that has nothing to do
|
|
649
|
+
// with the generated code.
|
|
650
|
+
const alwaysFailRel = "dev-factory/.hsewt-selftest-alwaysfail.test.mjs";
|
|
651
|
+
const alwaysFailAbs = join(wt, alwaysFailRel);
|
|
652
|
+
writeFileSync(alwaysFailAbs, "import { test } from \"node:test\";\nimport assert from \"node:assert/strict\";\ntest(\"always fails\", () => { assert.equal(1, 2); });\n");
|
|
653
|
+
try {
|
|
654
|
+
const redBaseline = baselineTest(ctx, wt, alwaysFailRel);
|
|
655
|
+
check("baselineTest: RED on an always-failing node:test fixture (pass=false)", redBaseline.pass === false);
|
|
656
|
+
} finally {
|
|
657
|
+
try { if (existsSync(alwaysFailAbs)) rmSync(alwaysFailAbs, { force: true }); } catch { /* best-effort */ }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// HS-AY5K edge (a): a NEW-FILE task's test doesn't exist yet at baseline —
|
|
661
|
+
// must report pass:true + notFound:true (expected-new), NEVER a red baseline.
|
|
662
|
+
const newFileTestRel = "dev-factory/.hsewt-selftest-newfile.test.mjs";
|
|
663
|
+
const newFileTestAbs = join(wt, newFileTestRel);
|
|
664
|
+
check("precondition: the NEW-FILE test fixture truly doesn't exist at baseline", !existsSync(newFileTestAbs));
|
|
665
|
+
const newFileBaseline = baselineTest(ctx, wt, newFileTestRel);
|
|
666
|
+
check("baselineTest: a NEW-FILE test (missing at baseline) reports pass:true + notFound:true (never red)",
|
|
667
|
+
newFileBaseline.pass === true && newFileBaseline.notFound === true);
|
|
668
|
+
|
|
669
|
+
// HS-AY5K edge (c): a plain exit-code self-test script (no node:test/vitest
|
|
670
|
+
// import) must route to runPlainSelfTestScript, not fall through to vitest (which
|
|
671
|
+
// would report "no test files found" — a permanently red gate unrelated to the
|
|
672
|
+
// candidate). Both real-repo conventions: inline `--self-test` argv guard, and the
|
|
673
|
+
// bare `<name>.selftest.mjs` companion-file convention (scripts/validate-vault-ids
|
|
674
|
+
// .selftest.mjs is the real fixture that motivated this).
|
|
675
|
+
const argvGuardRel = "dev-factory/.hsewt-selftest-argvguard.mjs";
|
|
676
|
+
const argvGuardAbs = join(wt, argvGuardRel);
|
|
677
|
+
const argvGuardPassSrc = "if (process.argv.includes(\"--self-test\")) {\n console.log(\"self-test PASS\");\n process.exit(0);\n}\n";
|
|
678
|
+
writeFileSync(argvGuardAbs, argvGuardPassSrc);
|
|
679
|
+
try {
|
|
680
|
+
check("isPlainSelfTestScript detects the inline --self-test argv-guard convention", isPlainSelfTestScript(argvGuardAbs) === true);
|
|
681
|
+
check("isNodeTestFile does NOT misclassify a plain argv-guard script", isNodeTestFile(argvGuardAbs) === false);
|
|
682
|
+
const argvGuardGreen = baselineTest(ctx, wt, argvGuardRel);
|
|
683
|
+
check("baselineTest: argv-guard script routed correctly → GREEN (exit 0)", argvGuardGreen.pass === true);
|
|
684
|
+
writeFileSync(argvGuardAbs, "if (process.argv.includes(\"--self-test\")) {\n console.log(\"self-test FAIL\");\n process.exit(1);\n}\n");
|
|
685
|
+
const argvGuardRed = baselineTest(ctx, wt, argvGuardRel);
|
|
686
|
+
check("baselineTest: argv-guard script exit 1 → RED", argvGuardRed.pass === false);
|
|
687
|
+
} finally {
|
|
688
|
+
try { if (existsSync(argvGuardAbs)) rmSync(argvGuardAbs, { force: true }); } catch { /* best-effort */ }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const companionRel = "dev-factory/.hsewt-selftest-fixture.selftest.mjs";
|
|
692
|
+
const companionAbs = join(wt, companionRel);
|
|
693
|
+
writeFileSync(companionAbs, "console.log(\"self-test PASS\");\nprocess.exit(0);\n");
|
|
694
|
+
try {
|
|
695
|
+
check("isPlainSelfTestScript detects the *.selftest.mjs companion-file convention", isPlainSelfTestScript(companionAbs) === true);
|
|
696
|
+
const companionGreen = baselineTest(ctx, wt, companionRel);
|
|
697
|
+
check("baselineTest: companion .selftest.mjs runs BARE (no flag) → GREEN (exit 0)", companionGreen.pass === true);
|
|
698
|
+
writeFileSync(companionAbs, "console.log(\"self-test FAIL\");\nprocess.exit(1);\n");
|
|
699
|
+
const companionRed = baselineTest(ctx, wt, companionRel);
|
|
700
|
+
check("baselineTest: companion .selftest.mjs exit 1 → RED", companionRed.pass === false);
|
|
701
|
+
// Restore green content — runGate writes `code` to `task.target`, not `task.test`;
|
|
702
|
+
// the .selftest.mjs file on disk is what actually gets EXECUTED as the gate's test.
|
|
703
|
+
writeFileSync(companionAbs, "console.log(\"self-test PASS\");\nprocess.exit(0);\n");
|
|
704
|
+
const companionTargetRel = companionRel.replace(/\.selftest\.mjs$/, ".mjs");
|
|
705
|
+
const companionGate = runGate(ctx, wt, { target: companionTargetRel, test: companionRel }, "// candidate target content\n", base.count);
|
|
706
|
+
check("runGate routes a .selftest.mjs test through the plain self-test runner (green)", companionGate.pass === true && companionGate.testOk === true);
|
|
707
|
+
} finally {
|
|
708
|
+
try { if (existsSync(companionAbs)) rmSync(companionAbs, { force: true }); } catch { /* best-effort */ }
|
|
709
|
+
try {
|
|
710
|
+
const companionTargetAbs = join(wt, companionRel.replace(/\.selftest\.mjs$/, ".mjs"));
|
|
711
|
+
if (existsSync(companionTargetAbs)) rmSync(companionTargetAbs, { force: true });
|
|
712
|
+
} catch { /* best-effort */ }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// HS-QSQX regression: the stranded-worktree sweep must reclaim a dead-owner worktree
|
|
716
|
+
// links-first (never following its junction into the real node_modules) AND preserve a
|
|
717
|
+
// live-owner worktree. Canary the real root node_modules count across the whole sweep.
|
|
718
|
+
const rootNM = join(ctx.repo, "node_modules");
|
|
719
|
+
const canaryBefore = readdirSync(rootNM).length;
|
|
720
|
+
// Create the LIVE one first — setupWorktree sweeps on entry, so a stranded worktree made first
|
|
721
|
+
// would be reclaimed by the next setup before the explicit sweep could see it.
|
|
722
|
+
const live = setupWorktree(ctx, "sweep-live"); // owner = this live process
|
|
723
|
+
const stranded = setupWorktree(ctx, "sweep-stranded"); // real worktree + live junctions
|
|
724
|
+
writeFileSync(join(stranded, OWNER_FILE), JSON.stringify({ pid: 999999999, startedAt: "x", label: "sweep-stranded" })); // fake a DEAD owner
|
|
725
|
+
const sweep = sweepStaleWorktrees(ctx, { quiet: true });
|
|
726
|
+
check("sweep reclaimed the dead-owner worktree", !existsSync(stranded) && sweep.swept.includes("sweep-stranded"));
|
|
727
|
+
check("sweep PRESERVED the live-owner worktree", existsSync(live) && sweep.skipped.includes("sweep-live"));
|
|
728
|
+
check(`sweep left the real root node_modules intact (canary ${canaryBefore})`, readdirSync(rootNM).length === canaryBefore);
|
|
729
|
+
teardownWorktree(ctx, live, true);
|
|
730
|
+
} catch (e) {
|
|
731
|
+
check(`setup/baseline threw: ${e.message}`, false);
|
|
732
|
+
} finally {
|
|
733
|
+
if (wt) {
|
|
734
|
+
const clean = teardownWorktree(ctx, wt);
|
|
735
|
+
check("teardown removed the worktree (no stray dir/junction)", clean && !existsSync(wt));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
console.log(`\n${ok ? "✓ self-test PASS — worktree isolation + baseline + clean teardown verified." : "✗ self-test FAIL"}`);
|
|
739
|
+
process.exit(ok ? 0 : 1);
|
|
740
|
+
}
|