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,3377 @@
|
|
|
1
|
+
// How the on-deck brief is rendered: heyspex_next_on_deck / next_review /
|
|
2
|
+
// next_resolution / suggest_pillar / resolve_prerequisite, the enrichOnDeckWith*
|
|
3
|
+
// pipeline (plans, breakage, conflicts, amendments, alignment gate, WIP block,
|
|
4
|
+
// claim target, run front, agent orchestration, memories) and the HTTP augments. Extracted from cli.mjs under
|
|
5
|
+
// [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
|
|
6
|
+
//
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Amendment tier ([[HS-6DMR-EPIC-amendment-tier-reverse-resolver-amended]]) —
|
|
9
|
+
// partial (non-terminal) supersession. `amends:` lives ONLY on the amending
|
|
10
|
+
// (newer, canonical) doc; the targets are never edited. The reverse map ("what
|
|
11
|
+
// amends spec X") is COMPUTED by scanning every spec's `amends:` list. On the
|
|
12
|
+
// /hs-next brief we warn the breakdown / execution agent that the on-deck epic
|
|
13
|
+
// is amended and inject the source's `## Amends` delta rows so the override is
|
|
14
|
+
// read BEFORE decomposing. Mirrors enrichOnDeckWithConflicts. Contract: AMENDS
|
|
15
|
+
// v1 (specs/research/HS-3RU4-RES-partial-supersession-amendment-tier-for-.md).
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Claim-target banner ([[HS-TASK-NJPH]]) — the legibility surface for the
|
|
20
|
+
// /hs-next on-deck response. Bookended at top + bottom of prompt_markdown
|
|
21
|
+
// so the user always sees the hierarchy + intent verb before authorizing a
|
|
22
|
+
// claim. Vault invariant: a spec is claimable iff it is its own current
|
|
23
|
+
// ClaimTarget (console endpoint enforces this in T4).
|
|
24
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Agent orchestration composer — produces two outputs:
|
|
28
|
+
// • `user_prompt` — user-facing markdown (claim question, drift summary)
|
|
29
|
+
// • `agent_dispatch` — structured routing object (reply→action, failure modes)
|
|
30
|
+
//
|
|
31
|
+
// The split exists because the prior single-`prompt_markdown` shape leaked the
|
|
32
|
+
// "🤖 Agent dispatch (do not paraphrase)" tables + "Failure modes" bullets into
|
|
33
|
+
// the user's terminal output. The agent now reads `agent_dispatch` as a typed
|
|
34
|
+
// object it decodes, and the leak surface is gone. Schema:
|
|
35
|
+
// `specs/schemas/agent-dispatch.schema.json`. Filed under [[HS-TASK-HSNX]];
|
|
36
|
+
// part of the [[HS-EPIC-MCPX]] wire-protocol redesign.
|
|
37
|
+
//
|
|
38
|
+
// The orchestration originally lived in `.claude/commands/hs-next.md` —
|
|
39
|
+
// migrated here to close the SaaS-customer corruption surface
|
|
40
|
+
// ([[workflow-orchestration-belongs-in-mcp-not-claude-commands]],
|
|
41
|
+
// [[HS-RES-J6CD-migrate-slash-command-orchestration-from]]).
|
|
42
|
+
//
|
|
43
|
+
// State-dispatched: fresh-summary, drift-summary, needs-breakdown-summary, and
|
|
44
|
+
// full-mode each get phase-appropriate dispatch.
|
|
45
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
import { ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier, resolveVaultWriteMode, withDegradationScope } from './read-path.mjs';
|
|
48
|
+
import { consoleFetch, controlError, controlOk, enrichConsoleFailurePayloadAsync } from './console-transport.mjs';
|
|
49
|
+
import { disambiguateClaimSpecId, findSpecFile, loadRoadmap, loadSpec, loadSpecByIdViaTier, loadTask, loadTaskViaGraph, parseFrontmatter, prefetchCloudTaskGraph, wikilinkTarget } from './vault-io.mjs';
|
|
50
|
+
import { isAbsolute, join } from 'node:path';
|
|
51
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
+
import { detectUITags, extractSummary, loadDesignMemories, loadDesignMemoriesViaTier } from './memory-tools.mjs';
|
|
53
|
+
import { attachWhyNowFields, buildDesignContextSection, buildOnDeckExplanation, deriveTaskBrief, extractNextStepBlock, loadFocusOverride, loadPillarTierMapViaTier, loadVaultSpecsForSchedulerViaTier, readSchedulerCacheOrderIds, resolveNextOnDeck } from './on-deck-scheduler.mjs';
|
|
54
|
+
import { normalizeRunFrontToken, shouldAutoClaimOnDeck } from './run-front.mjs';
|
|
55
|
+
import { assessBriefReadiness, buildClarificationPrompt, buildDecompositionPromptMarkdown, buildDriftResolutionPrompt, childKindFor, detectCorpusDrift, extractDecompositionContext, getChildRefs, readReadinessFloorSettings } from './alignment-gate.mjs';
|
|
56
|
+
import { formatDisplayId, parseSpecId } from './spec-id-lib.mjs';
|
|
57
|
+
import { hydratePromptWithPlans } from './plan-ingest.mjs';
|
|
58
|
+
import { collectEpicRelatedLinks, detectSpecConflicts } from './plan-push.mjs';
|
|
59
|
+
import { computeClaimTarget, mapStateToHint } from './blocker-resolver.mjs';
|
|
60
|
+
import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
|
|
61
|
+
import { buildSummaryConsoleWritePrompt, buildSummaryFillInPrompt } from './summary-contract.mjs';
|
|
62
|
+
import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
|
|
63
|
+
import { resolveNextResolutionPayload } from './next-resolution.mjs';
|
|
64
|
+
import { resolveNextReviewPayload } from './next-review.mjs';
|
|
65
|
+
import { injectNeonFocusPin } from './focus-tools.mjs';
|
|
66
|
+
import { walkSpecListPages } from './list-tools.mjs';
|
|
67
|
+
import { importDevModeCore } from './dev-mode-core.mjs';
|
|
68
|
+
import { PACKAGE_ROOT } from './package-root.mjs';
|
|
69
|
+
import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
70
|
+
|
|
71
|
+
// scripts/lib/dev-mode.mjs is a CODE-repo artifact that ships with this
|
|
72
|
+
// package — since the repo split (2026-08-31) the vault root no longer holds
|
|
73
|
+
// it, so resolve it relative to THIS file's checkout first and fall back to
|
|
74
|
+
// the vault root only for pre-split layouts where they were the same repo.
|
|
75
|
+
// `HS_PKG_ROOT` wins when set (same precedence as dev-tools.mjs's
|
|
76
|
+
// CHECKOUT_ROOT / plan-review.mjs's resolveCodeAuditRoots).
|
|
77
|
+
const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Internals exposed for the [[HS-TASK-I2Y8]] corpus-diff regression test (the
|
|
81
|
+
* derived path vs the historical NEXT-STEP-excerpt path across the whole vault).
|
|
82
|
+
* Not part of the public MCP surface.
|
|
83
|
+
*/
|
|
84
|
+
export const __briefTestables = {
|
|
85
|
+
deriveTaskBrief,
|
|
86
|
+
extractNextStepBlock,
|
|
87
|
+
loadSpec,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Append canonical / resolved external plan content to on-deck prompts.
|
|
92
|
+
* @param {string} vaultRoot
|
|
93
|
+
* @param {object} result
|
|
94
|
+
* @param {object} [args]
|
|
95
|
+
*/
|
|
96
|
+
export function enrichOnDeckWithPlans(vaultRoot, result, args = {}) {
|
|
97
|
+
if (!result?.spec_path || !result?.prompt_markdown) return result;
|
|
98
|
+
|
|
99
|
+
const spec = loadSpec(vaultRoot, result.spec_path);
|
|
100
|
+
if (!spec) return result;
|
|
101
|
+
|
|
102
|
+
const persist =
|
|
103
|
+
args.persist_plan_ingest === true || args.persist_plan_ingest === 'true';
|
|
104
|
+
|
|
105
|
+
const hydrated = hydratePromptWithPlans(
|
|
106
|
+
vaultRoot,
|
|
107
|
+
result.spec_path,
|
|
108
|
+
spec.body,
|
|
109
|
+
result.prompt_markdown,
|
|
110
|
+
{ persist },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const enriched = {
|
|
114
|
+
...result,
|
|
115
|
+
prompt_markdown: hydrated.prompt_markdown,
|
|
116
|
+
plan_hydration: {
|
|
117
|
+
had_canonical_in_spec: hydrated.had_canonical_in_spec,
|
|
118
|
+
plans_in_prompt: hydrated.plans_in_prompt,
|
|
119
|
+
missing_plans: hydrated.missing_plans,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (hydrated.persist_result?.written) {
|
|
124
|
+
enriched.plan_hydration.persisted_to_spec = true;
|
|
125
|
+
enriched.plan_hydration.spec_path = hydrated.persist_result.spec_path;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return enriched;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pull the `## Expected Breakage & Consequences` section out of a spec body
|
|
133
|
+
* verbatim ([[spec-hierarchy]] §Expected Breakage & Consequences). Returns the
|
|
134
|
+
* heading-through-next-heading slice, or null when the section is absent.
|
|
135
|
+
*/
|
|
136
|
+
export function extractBreakageSection(body) {
|
|
137
|
+
if (!body) return null;
|
|
138
|
+
const lines = body.split('\n');
|
|
139
|
+
let start = -1;
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (/^##\s+Expected Breakage & Consequences\s*$/i.test(lines[i].trim())) {
|
|
142
|
+
start = i;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (start === -1) return null;
|
|
147
|
+
let end = lines.length;
|
|
148
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
149
|
+
if (/^#{1,2}\s+/.test(lines[i])) {
|
|
150
|
+
end = i;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const section = lines.slice(start, end).join('\n').trim();
|
|
155
|
+
return section.length > 0 ? section : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Surface the spec's `## Expected Breakage & Consequences` section at the top of
|
|
160
|
+
* the execution body, just above NEXT STEP, so the agent reads the anticipated
|
|
161
|
+
* cascade *before* coding and a transient rise in tsc/test errors reads as the
|
|
162
|
+
* expected consequence, not a regression ([[spec-hierarchy]]). No-op when the
|
|
163
|
+
* spec carries no such section.
|
|
164
|
+
*/
|
|
165
|
+
export function enrichOnDeckWithExpectedBreakage(vaultRoot, result) {
|
|
166
|
+
if (!result?.spec_path || !result?.prompt_markdown) return result;
|
|
167
|
+
const spec = loadSpec(vaultRoot, result.spec_path);
|
|
168
|
+
if (!spec) return result;
|
|
169
|
+
const section = extractBreakageSection(spec.body);
|
|
170
|
+
if (!section) return result;
|
|
171
|
+
return {
|
|
172
|
+
...result,
|
|
173
|
+
prompt_markdown: `${section}\n\n---\n\n${result.prompt_markdown}`,
|
|
174
|
+
expected_breakage: { present: true },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildConflictPromptSection(conflicts, relatedLinks) {
|
|
179
|
+
const lines = ['## Spec conflicts (pre-flight)', ''];
|
|
180
|
+
if (conflicts.length === 0) {
|
|
181
|
+
lines.push(
|
|
182
|
+
'No behavioral conflicts detected between this epic’s NEXT STEP / acceptance criteria and linked specs.',
|
|
183
|
+
);
|
|
184
|
+
if (relatedLinks.length > 0) {
|
|
185
|
+
lines.push('', '**Linked context:**', ...relatedLinks.map((r) => `- [[${r}]]`));
|
|
186
|
+
}
|
|
187
|
+
return lines.join('\n');
|
|
188
|
+
}
|
|
189
|
+
lines.push(
|
|
190
|
+
`⚠️ **${conflicts.length} conflict(s)** — resolve before implementation (Console On Deck · \`/hs-resolve\`):`,
|
|
191
|
+
'',
|
|
192
|
+
);
|
|
193
|
+
for (const c of conflicts) {
|
|
194
|
+
lines.push(`- **Epic:** ${c.epic_claim}`);
|
|
195
|
+
lines.push(` **[[${c.spec}]]:** ${c.spec_claim} (${c.section})`);
|
|
196
|
+
}
|
|
197
|
+
lines.push('', 'Run **`/hs-resolve`** or update the epic / protocol before proceeding.');
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function enrichOnDeckWithConflicts(vaultRoot, result) {
|
|
202
|
+
if (!result?.spec_path) return result;
|
|
203
|
+
|
|
204
|
+
const spec = loadSpec(vaultRoot, result.spec_path);
|
|
205
|
+
if (!spec || spec.frontmatter.type !== 'epic') return result;
|
|
206
|
+
|
|
207
|
+
const relatedLinks = collectEpicRelatedLinks(
|
|
208
|
+
spec.frontmatter.related || [],
|
|
209
|
+
spec.body,
|
|
210
|
+
);
|
|
211
|
+
const { conflicts, linked_specs_checked } = detectSpecConflicts(
|
|
212
|
+
vaultRoot,
|
|
213
|
+
spec.body,
|
|
214
|
+
relatedLinks,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const conflictSection = buildConflictPromptSection(conflicts, relatedLinks);
|
|
218
|
+
const referencesBlock =
|
|
219
|
+
relatedLinks.length > 0
|
|
220
|
+
? `\n\n## Related context (read before implementing)\n\n${relatedLinks.map((r) => `- [[${r}]]`).join('\n')}`
|
|
221
|
+
: '';
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
...result,
|
|
225
|
+
prompt_markdown: `${conflictSection}${referencesBlock}\n\n---\n\n${result.prompt_markdown}`,
|
|
226
|
+
conflict_analysis: {
|
|
227
|
+
has_conflicts: conflicts.length > 0,
|
|
228
|
+
conflict_count: conflicts.length,
|
|
229
|
+
conflicts,
|
|
230
|
+
linked_specs_checked,
|
|
231
|
+
related_links: relatedLinks,
|
|
232
|
+
resolve_command: `/hs-resolve ${result.spec_id}`,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Normalize a spec reference (wikilink | basename | id) to its stable id token
|
|
238
|
+
// (`HS-AGDG-MIL`), falling back to the bare basename when the token is
|
|
239
|
+
// unrecognizable. Matching on the id token — not the full slugged basename —
|
|
240
|
+
// keeps the reverse map robust to slug drift between an epic's `milestone:`
|
|
241
|
+
// back-ref and a source's `amends:` entry, while still honoring the contract's
|
|
242
|
+
// basename comparison (both share the same leading id token).
|
|
243
|
+
export function amendTargetKey(ref) {
|
|
244
|
+
const inner = wikilinkTarget(String(ref ?? ''));
|
|
245
|
+
if (!inner) return null;
|
|
246
|
+
const trimmed = inner.trim();
|
|
247
|
+
if (!trimmed) return null;
|
|
248
|
+
const m = trimmed.match(
|
|
249
|
+
/^(HS-[A-Z0-9]+-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW))/i,
|
|
250
|
+
);
|
|
251
|
+
return m ? m[1] : trimmed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Bare basename of a vault-relative spec path (tolerates `/` or `\` separators).
|
|
255
|
+
export function specPathBasename(specPath) {
|
|
256
|
+
if (typeof specPath !== 'string') return '';
|
|
257
|
+
const tail = specPath.split(/[\\/]/).pop() || '';
|
|
258
|
+
return tail.replace(/\.md$/i, '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Parse the `## Amends` delta table out of an amending doc's body. Returns one
|
|
262
|
+
// entry per data row: { target (raw first-cell wikilink basename), targetKey,
|
|
263
|
+
// epics, override }. Tolerates the canonical HS-VZCL column shape
|
|
264
|
+
// (`Amended target | Epic(s) | What this milestone overrides`), the header +
|
|
265
|
+
// separator rows, and any trailing prose after the table.
|
|
266
|
+
export function parseAmendsDeltaTable(body) {
|
|
267
|
+
if (typeof body !== 'string' || !body) return [];
|
|
268
|
+
const lines = body.split(/\r?\n/);
|
|
269
|
+
const startIdx = lines.findIndex((l) => /^#{1,6}\s+Amends\b/i.test(l.trim()));
|
|
270
|
+
if (startIdx === -1) return [];
|
|
271
|
+
const rows = [];
|
|
272
|
+
let seenTable = false;
|
|
273
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
274
|
+
const trimmed = lines[i].trim();
|
|
275
|
+
if (/^#{1,6}\s/.test(trimmed)) {
|
|
276
|
+
if (seenTable) break;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!trimmed.startsWith('|')) {
|
|
280
|
+
if (seenTable) break; // table (and any leading prose) ended
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
seenTable = true;
|
|
284
|
+
const cells = trimmed
|
|
285
|
+
.replace(/^\|/, '')
|
|
286
|
+
.replace(/\|$/, '')
|
|
287
|
+
.split('|')
|
|
288
|
+
.map((c) => c.trim());
|
|
289
|
+
// Skip the `|---|---|` separator row.
|
|
290
|
+
if (cells.every((c) => c === '' || /^:?-{2,}:?$/.test(c))) continue;
|
|
291
|
+
const firstCell = cells[0] || '';
|
|
292
|
+
// Only wikilink-bearing rows are data; the `Amended target` header is skipped.
|
|
293
|
+
if (!/\[\[/.test(firstCell)) continue;
|
|
294
|
+
rows.push({
|
|
295
|
+
target: wikilinkTarget(firstCell),
|
|
296
|
+
targetKey: amendTargetKey(firstCell),
|
|
297
|
+
epics: cells[1] || '',
|
|
298
|
+
override: cells[2] || '',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return rows;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Scan the vault for amending sources whose `amends:` list contains any of the
|
|
305
|
+
// on-deck spec's target keys (its own id and/or its parent milestone). Returns
|
|
306
|
+
// `{ source, matchedKey, rows }[]`, rows being the delta-table entries that
|
|
307
|
+
// resolve to one of those targets.
|
|
308
|
+
export function collectAmendmentSources(vaultRoot, targetKeys, selfKey) {
|
|
309
|
+
const out = [];
|
|
310
|
+
const dirs = [
|
|
311
|
+
'specs/milestones',
|
|
312
|
+
'specs/epics',
|
|
313
|
+
'specs/tasks',
|
|
314
|
+
'specs/roadmaps',
|
|
315
|
+
'specs/research',
|
|
316
|
+
];
|
|
317
|
+
for (const dir of dirs) {
|
|
318
|
+
const full = join(vaultRoot, dir);
|
|
319
|
+
if (!existsSync(full)) continue;
|
|
320
|
+
let files;
|
|
321
|
+
try {
|
|
322
|
+
files = readdirSync(full);
|
|
323
|
+
} catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
if (!file.endsWith('.md')) continue;
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = parseFrontmatter(readFileSync(join(full, file), 'utf-8'));
|
|
331
|
+
} catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const amends = parsed.frontmatter?.amends;
|
|
335
|
+
if (!Array.isArray(amends) || amends.length === 0) continue;
|
|
336
|
+
const sourceBasename = file.replace(/\.md$/i, '');
|
|
337
|
+
const sourceKey = amendTargetKey(parsed.frontmatter.id || sourceBasename);
|
|
338
|
+
// Never report a spec as amended by itself.
|
|
339
|
+
if (sourceKey && selfKey && sourceKey === selfKey) continue;
|
|
340
|
+
let matchedKey = null;
|
|
341
|
+
for (const entry of amends) {
|
|
342
|
+
const k = amendTargetKey(entry);
|
|
343
|
+
if (k && targetKeys.has(k)) {
|
|
344
|
+
matchedKey = k;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!matchedKey) continue;
|
|
349
|
+
const rows = parseAmendsDeltaTable(parsed.body).filter(
|
|
350
|
+
(r) => r.targetKey && targetKeys.has(r.targetKey),
|
|
351
|
+
);
|
|
352
|
+
out.push({ source: sourceBasename, matchedKey, rows });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Cloud-reliant sibling of `collectAmendmentSources` ([[HS-DJWP-MCPC-TASK]]
|
|
360
|
+
* "Amendment scan" family, round 2 — `amends` is now a projectable
|
|
361
|
+
* `GET /api/specs?fields=` field, added to `SpecSummary`
|
|
362
|
+
* (`spex-console/src/lib/vault-shared.ts` + `vault.ts` `toSummary()`,
|
|
363
|
+
* [[HS-DJWP-MCPC-TASK]] round-2 correction: the task Scope explicitly allows
|
|
364
|
+
* extending that ONE route's projection).
|
|
365
|
+
*
|
|
366
|
+
* `collectAmendmentSources` itself stays untouched (the `diskRead` branch).
|
|
367
|
+
* In `neon` tier:
|
|
368
|
+
* 1. ONE `GET /api/specs?fields=amends,basename,type` lists every spec's
|
|
369
|
+
* normalized `amends` targets — no per-file frontmatter read.
|
|
370
|
+
* 2. Filtered client-side to the five kinds the disk scan walks
|
|
371
|
+
* (milestone/epic/task/roadmap/research) and to non-empty `amends`.
|
|
372
|
+
* 3. Body text (`parseAmendsDeltaTable`) is fetched via `GET
|
|
373
|
+
* /api/specs/[id]` ONLY for sources whose `amends` genuinely matches one
|
|
374
|
+
* of `targetKeys` — the rare case — never for the full candidate set.
|
|
375
|
+
*
|
|
376
|
+
* NOT wired into `enrichOnDeckWithAmendments` (this function's only caller
|
|
377
|
+
* today): that enricher runs inside `resolveNextOnDeckPayload`'s fully
|
|
378
|
+
* synchronous `runEnricherChain`, which 14+ call sites in `cli.test.mjs` call
|
|
379
|
+
* directly and destructure `{ result, error }` with no `await` — the exact
|
|
380
|
+
* same "must stay sync" hazard `handleRunPreflight` / `handleRecordAlignment`
|
|
381
|
+
* hit in round 1. Converting it would need `resolveNextOnDeckPayload` itself
|
|
382
|
+
* to go async, breaking all of them. This function is a genuine, tested,
|
|
383
|
+
* ready-to-call primitive; wiring it into the live on-deck enricher chain is
|
|
384
|
+
* the same larger, deferred piece of work as the rest of that chain
|
|
385
|
+
* (conflicts / WIP / alignment-drift / claim-target), not a gap specific to
|
|
386
|
+
* amendments.
|
|
387
|
+
*/
|
|
388
|
+
export async function collectAmendmentSourcesViaTier(vaultRoot, targetKeys, selfKey, { tier, consoleUrl }) {
|
|
389
|
+
return readViaSelector({
|
|
390
|
+
tier,
|
|
391
|
+
diskRead: () => collectAmendmentSources(vaultRoot, targetKeys, selfKey),
|
|
392
|
+
neonRead: async () => {
|
|
393
|
+
// The route answers a bounded page now
|
|
394
|
+
// ([[HS-C6FO-8KK5-TASK-keyset-spec-list-endpoint-query-layer]]); this
|
|
395
|
+
// scan needs every candidate, so it follows `next_cursor` to the end
|
|
396
|
+
// ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
|
|
397
|
+
const rows = await walkSpecListPages(consoleFetch, '/api/specs?fields=amends,basename,type');
|
|
398
|
+
const wantedTypes = new Set(['milestone', 'epic', 'task', 'roadmap', 'research']);
|
|
399
|
+
const candidates = rows.filter(
|
|
400
|
+
(r) => wantedTypes.has(r.type) && Array.isArray(r.amends) && r.amends.length > 0,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const out = [];
|
|
404
|
+
for (const row of candidates) {
|
|
405
|
+
const sourceBasename = row.basename;
|
|
406
|
+
const sourceKey = amendTargetKey(row.id || sourceBasename);
|
|
407
|
+
// Never report a spec as amended by itself.
|
|
408
|
+
if (sourceKey && selfKey && sourceKey === selfKey) continue;
|
|
409
|
+
let matchedKey = null;
|
|
410
|
+
for (const entry of row.amends) {
|
|
411
|
+
const k = amendTargetKey(entry);
|
|
412
|
+
if (k && targetKeys.has(k)) {
|
|
413
|
+
matchedKey = k;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!matchedKey) continue;
|
|
418
|
+
|
|
419
|
+
// Body text is only fetched for a genuine match — the rare case.
|
|
420
|
+
let body = '';
|
|
421
|
+
const specRes = await consoleFetch(`/api/specs/${encodeURIComponent(row.id)}`);
|
|
422
|
+
if (specRes.ok) body = specRes.body?.spec?.body ?? '';
|
|
423
|
+
const deltaRows = parseAmendsDeltaTable(body).filter(
|
|
424
|
+
(r) => r.targetKey && targetKeys.has(r.targetKey),
|
|
425
|
+
);
|
|
426
|
+
out.push({ source: sourceBasename, matchedKey, rows: deltaRows });
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
},
|
|
430
|
+
routeLabel: 'GET /api/specs?fields=amends',
|
|
431
|
+
consoleUrl,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function buildAmendmentPromptSection(sources) {
|
|
436
|
+
const lines = ['## Amendments (partial supersession — read before implementing)', ''];
|
|
437
|
+
for (const s of sources) {
|
|
438
|
+
lines.push(
|
|
439
|
+
`⚠️ **Amended by [[${s.source}]]** — read it first; it overrides these sections on conflict.`,
|
|
440
|
+
'',
|
|
441
|
+
);
|
|
442
|
+
if (s.rows.length > 0) {
|
|
443
|
+
lines.push('| Amended target | Epic(s) | Override |', '|---|---|---|');
|
|
444
|
+
for (const r of s.rows) {
|
|
445
|
+
const target = r.target ? `[[${r.target}]]` : '';
|
|
446
|
+
lines.push(`| ${target} | ${r.epics || ''} | ${r.override || ''} |`);
|
|
447
|
+
}
|
|
448
|
+
lines.push('');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return lines.join('\n').trimEnd();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Prepend an amendment warning + the source's `## Amends` delta rows when the
|
|
455
|
+
// on-deck epic (or its parent milestone) is amended. Mirrors
|
|
456
|
+
// enrichOnDeckWithConflicts: loads the spec, epics only, prepends above the
|
|
457
|
+
// existing brief with a `---` separator, and attaches a structured
|
|
458
|
+
// `amendment_analysis` field. Additive / no-op when nothing amends the spec.
|
|
459
|
+
export function enrichOnDeckWithAmendments(vaultRoot, result) {
|
|
460
|
+
if (!result?.spec_path || !result?.prompt_markdown) return result;
|
|
461
|
+
|
|
462
|
+
const spec = loadSpec(vaultRoot, result.spec_path);
|
|
463
|
+
if (!spec || spec.frontmatter.type !== 'epic') return result;
|
|
464
|
+
|
|
465
|
+
// The on-deck epic is "amended" when it — OR its parent milestone — is a
|
|
466
|
+
// target of some source's `amends:` list. In HS-VZCL's real case the targets
|
|
467
|
+
// are the MILESTONES and the /hs-next unit is an epic under them, so resolve
|
|
468
|
+
// the epic UP to its milestone (and parent) and match on all three keys.
|
|
469
|
+
const selfKey = amendTargetKey(spec.frontmatter.id || specPathBasename(result.spec_path));
|
|
470
|
+
const targetKeys = new Set(
|
|
471
|
+
[
|
|
472
|
+
selfKey,
|
|
473
|
+
amendTargetKey(spec.frontmatter.milestone),
|
|
474
|
+
amendTargetKey(spec.frontmatter.parent),
|
|
475
|
+
].filter(Boolean),
|
|
476
|
+
);
|
|
477
|
+
if (targetKeys.size === 0) return result;
|
|
478
|
+
|
|
479
|
+
const sources = collectAmendmentSources(vaultRoot, targetKeys, selfKey);
|
|
480
|
+
if (sources.length === 0) return result;
|
|
481
|
+
|
|
482
|
+
const section = buildAmendmentPromptSection(sources);
|
|
483
|
+
return {
|
|
484
|
+
...result,
|
|
485
|
+
prompt_markdown: `${section}\n\n---\n\n${result.prompt_markdown}`,
|
|
486
|
+
amendment_analysis: {
|
|
487
|
+
is_amended: true,
|
|
488
|
+
amended_by: sources.map((s) => s.source),
|
|
489
|
+
matched_target_keys: [...targetKeys],
|
|
490
|
+
sources: sources.map((s) => ({
|
|
491
|
+
source: s.source,
|
|
492
|
+
matched_target_key: s.matchedKey,
|
|
493
|
+
rows: s.rows,
|
|
494
|
+
})),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export const ALIGNMENT_SKIP_STATUSES = new Set([
|
|
500
|
+
'in_review',
|
|
501
|
+
'closed',
|
|
502
|
+
'approved',
|
|
503
|
+
'hibernated',
|
|
504
|
+
'deferred',
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Pre-execution alignment gate ([[pre-execution-alignment-gate]]) — the FIRST
|
|
509
|
+
* pre-flight, upstream of conflict resolution. Runs on the focus-pinned epic or
|
|
510
|
+
* milestone before the agent executes its chain:
|
|
511
|
+
*
|
|
512
|
+
* - **no children** → HARD breakdown gate (robust decomposition brief).
|
|
513
|
+
* - **children + checkpoint fires** → HARD alignment-review gate (revisit the
|
|
514
|
+
* breakdown for drift). "Fire" = never reviewed, the breakdown changed, or
|
|
515
|
+
* the 7-day fuse expired.
|
|
516
|
+
* - **checkpoint ok** → pass the underlying result through untouched.
|
|
517
|
+
*
|
|
518
|
+
* Hard for both per the user's "AI agents are good at executing the wrong thing
|
|
519
|
+
* perfectly" stance; the auditable escape valve is `heyspex_record_alignment`
|
|
520
|
+
* with an `override_reason`. The gate REPLACES the result (no task brief) so the
|
|
521
|
+
* agent cannot skim past it — mirrors how a conflict-on-push blocks.
|
|
522
|
+
*
|
|
523
|
+
* SSOT invariant (locked by cli.test.mjs "alignment gate reads children from
|
|
524
|
+
* disk"): the breakdown decision is governed by the focus epic's children as
|
|
525
|
+
* parsed from the FILE (`loadSpec` → `readFileSync`), never by any index /
|
|
526
|
+
* cohort / console-response field. A stale derived index therefore cannot trip
|
|
527
|
+
* a phantom `needs_breakdown` gate. Do not refactor `getChildRefs(fm, …)` below
|
|
528
|
+
* to read from an index — that reintroduces the cache-vs-SSOT divergence that
|
|
529
|
+
* cost a session ~15 tool calls of forensic archaeology on HS-EPIC-FCSM.
|
|
530
|
+
*/
|
|
531
|
+
/**
|
|
532
|
+
* Which spec does the alignment gate actually run against?
|
|
533
|
+
*
|
|
534
|
+
* A milestone or epic pin gates itself. A TASK pin used to skip the gate
|
|
535
|
+
* outright ("task pins are not gated in v1") — and that was the back door: pin
|
|
536
|
+
* the task rather than its epic and the drift check never ran, even though a
|
|
537
|
+
* task brief is precisely what goes stale when its epic's corpus moves. A task
|
|
538
|
+
* has no children to break down, so it gates its PARENT epic instead.
|
|
539
|
+
*
|
|
540
|
+
* Returns `{ grain, fm, body, gatedVia }`, or null when there is nothing to gate
|
|
541
|
+
* (an unknown kind, a task with no resolvable parent, or a parent that is not
|
|
542
|
+
* actionable).
|
|
543
|
+
*/
|
|
544
|
+
export function resolveAlignmentGateTarget(vaultRoot, focus, focusSpec, deps = {}) {
|
|
545
|
+
const loadRef = deps.loadRef ?? ((ref) => {
|
|
546
|
+
try {
|
|
547
|
+
return loadTask(vaultRoot, ref);
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
const fm = focusSpec?.frontmatter || {};
|
|
553
|
+
const kind = String(focus?.kind || fm.type || '').toLowerCase();
|
|
554
|
+
|
|
555
|
+
if (kind === 'milestone') {
|
|
556
|
+
return { grain: 'milestone', fm, body: focusSpec?.body || '', gatedVia: 'self' };
|
|
557
|
+
}
|
|
558
|
+
if (kind === 'epic') {
|
|
559
|
+
return { grain: 'epic', fm, body: focusSpec?.body || '', gatedVia: 'self' };
|
|
560
|
+
}
|
|
561
|
+
if (kind !== 'task') return null;
|
|
562
|
+
|
|
563
|
+
const parentRef = fm.parent || fm.epic || fm.milestone || null;
|
|
564
|
+
if (!parentRef) return null;
|
|
565
|
+
const parent = loadRef(parentRef);
|
|
566
|
+
const parentFm = parent?.frontmatter || null;
|
|
567
|
+
if (!parentFm) return null;
|
|
568
|
+
|
|
569
|
+
const parentType = String(parentFm.type || '').toLowerCase();
|
|
570
|
+
if (parentType !== 'epic' && parentType !== 'milestone') return null;
|
|
571
|
+
// A closed/parked parent is not actionable — same rule the focus spec gets.
|
|
572
|
+
if (ALIGNMENT_SKIP_STATUSES.has(String(parentFm.status || '').toLowerCase())) return null;
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
grain: parentType,
|
|
576
|
+
fm: parentFm,
|
|
577
|
+
body: parent.body || '',
|
|
578
|
+
gatedVia: 'task_parent',
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function enrichOnDeckWithAlignment(vaultRoot, result, args = {}) {
|
|
583
|
+
if (!result || result.error) return result;
|
|
584
|
+
if (args.ignore_focus === true || args.ignore_focus === 'true') return result;
|
|
585
|
+
|
|
586
|
+
const focus = loadFocusOverride(vaultRoot, {
|
|
587
|
+
neonPinSpecId: args.neon_pin_spec_id,
|
|
588
|
+
neonSelectionSpecPath: args.neon_selection_spec_path,
|
|
589
|
+
});
|
|
590
|
+
if (!focus || !focus.spec_path) return result;
|
|
591
|
+
const focusSpec = loadSpec(vaultRoot, focus.spec_path);
|
|
592
|
+
if (!focusSpec) return result;
|
|
593
|
+
|
|
594
|
+
const focusFm = focusSpec.frontmatter || {};
|
|
595
|
+
const status = (focusFm.status || '').toLowerCase();
|
|
596
|
+
if (ALIGNMENT_SKIP_STATUSES.has(status)) return result; // not actionable — don't gate
|
|
597
|
+
|
|
598
|
+
// A task pin gates its parent epic rather than skipping the gate entirely.
|
|
599
|
+
const target = resolveAlignmentGateTarget(vaultRoot, focus, focusSpec);
|
|
600
|
+
if (!target) return result;
|
|
601
|
+
const { grain, fm } = target;
|
|
602
|
+
|
|
603
|
+
const id = fm.id || focus.spec_path;
|
|
604
|
+
const name = fm.name || id;
|
|
605
|
+
const loadRef = (ref) => loadTask(vaultRoot, ref);
|
|
606
|
+
const node = { id, name, frontmatter: fm, body: target.body };
|
|
607
|
+
const context = extractDecompositionContext(node, loadRef, grain);
|
|
608
|
+
const roadmapId = result.roadmap_id || args.roadmap_id;
|
|
609
|
+
|
|
610
|
+
const childRefs = getChildRefs(fm, grain);
|
|
611
|
+
const childSpecs = [];
|
|
612
|
+
for (const ref of childRefs) {
|
|
613
|
+
const child = loadRef(ref);
|
|
614
|
+
if (!child) continue;
|
|
615
|
+
childSpecs.push({
|
|
616
|
+
id: child.id || ref,
|
|
617
|
+
name: child.name || child.frontmatter?.name || ref,
|
|
618
|
+
status: child.status || child.frontmatter?.status || 'unknown',
|
|
619
|
+
body: child.body || '',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// No children (or none resolve) → HARD breakdown gate.
|
|
624
|
+
if (childSpecs.length === 0) {
|
|
625
|
+
const floor = readReadinessFloorSettings(vaultRoot);
|
|
626
|
+
const readiness = assessBriefReadiness({ name, context, floor });
|
|
627
|
+
if (!readiness.ready) {
|
|
628
|
+
const promptMarkdown = buildClarificationPrompt({
|
|
629
|
+
id,
|
|
630
|
+
name,
|
|
631
|
+
status: fm.status,
|
|
632
|
+
roadmapId,
|
|
633
|
+
missing: readiness.missing,
|
|
634
|
+
focusSpecPath: focus.spec_path,
|
|
635
|
+
grain,
|
|
636
|
+
});
|
|
637
|
+
return {
|
|
638
|
+
...buildAlignmentGateResponse({
|
|
639
|
+
gate: 'needs_breakdown',
|
|
640
|
+
grain,
|
|
641
|
+
id,
|
|
642
|
+
name,
|
|
643
|
+
status: fm.status,
|
|
644
|
+
roadmapId,
|
|
645
|
+
focusSpecPath: focus.spec_path,
|
|
646
|
+
focusReason: focus.reason,
|
|
647
|
+
promptMarkdown,
|
|
648
|
+
suggestedActions: [],
|
|
649
|
+
}),
|
|
650
|
+
status: 'needs_clarification',
|
|
651
|
+
source_kind: 'needs_clarification_gate',
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const promptMarkdown = buildDecompositionPromptMarkdown({
|
|
655
|
+
grain,
|
|
656
|
+
id,
|
|
657
|
+
name,
|
|
658
|
+
context,
|
|
659
|
+
roadmapId,
|
|
660
|
+
focusSpecPath: focus.spec_path,
|
|
661
|
+
});
|
|
662
|
+
return buildAlignmentGateResponse({
|
|
663
|
+
gate: 'needs_breakdown',
|
|
664
|
+
grain,
|
|
665
|
+
id,
|
|
666
|
+
name,
|
|
667
|
+
status: fm.status,
|
|
668
|
+
roadmapId,
|
|
669
|
+
focusSpecPath: focus.spec_path,
|
|
670
|
+
focusReason: focus.reason,
|
|
671
|
+
promptMarkdown,
|
|
672
|
+
suggestedActions: [
|
|
673
|
+
{
|
|
674
|
+
action: `file_${childKindFor(grain)}`,
|
|
675
|
+
mcp_tool: 'heyspex_record_alignment',
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Children exist — AGV2 redefined drift as temporal corpus-staleness
|
|
682
|
+
// (related/depends_on/milestone last-modified newer than alignment_reviewed_at).
|
|
683
|
+
// Never a hard block when children exist; if drift is detected, prepend a
|
|
684
|
+
// soft question loop to the task brief and the agent poses keep/update/drop
|
|
685
|
+
// to the user. Silent passthrough when there's no baseline or no drift.
|
|
686
|
+
const loadFileMtime = (ref) => {
|
|
687
|
+
try {
|
|
688
|
+
const p = findSpecFile(vaultRoot, ref);
|
|
689
|
+
if (!p) return null;
|
|
690
|
+
const st = statSync(join(vaultRoot, p));
|
|
691
|
+
return st.mtimeMs;
|
|
692
|
+
} catch {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
const drift = detectCorpusDrift({
|
|
697
|
+
epic: { frontmatter: fm },
|
|
698
|
+
reviewedAt: fm.alignment_reviewed_at,
|
|
699
|
+
loadRef,
|
|
700
|
+
lastModified: loadFileMtime,
|
|
701
|
+
});
|
|
702
|
+
if (!drift.hasDrift) return result; // no corpus changes since baseline — silent passthrough
|
|
703
|
+
|
|
704
|
+
const driftSection = buildDriftResolutionPrompt({
|
|
705
|
+
grain,
|
|
706
|
+
id,
|
|
707
|
+
name,
|
|
708
|
+
context,
|
|
709
|
+
existingChildren: childSpecs,
|
|
710
|
+
stale: drift.stale,
|
|
711
|
+
roadmapId,
|
|
712
|
+
focusSpecPath: focus.spec_path,
|
|
713
|
+
});
|
|
714
|
+
return {
|
|
715
|
+
...result,
|
|
716
|
+
prompt_markdown: `${driftSection}\n\n---\n\n${result.prompt_markdown || ''}`,
|
|
717
|
+
alignment_drift: {
|
|
718
|
+
stale_corpus: drift.stale,
|
|
719
|
+
last_reviewed_at: fm.alignment_reviewed_at || null,
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Lightweight alignment-state probe for summary mode. Returns the alignment
|
|
726
|
+
* state of the focus-pinned epic/milestone WITHOUT building the full
|
|
727
|
+
* decomposition or drift-resolution brief. Mirrors the disk-SSOT discipline of
|
|
728
|
+
* enrichOnDeckWithAlignment (reads children from spec file, never from a
|
|
729
|
+
* derived index) but skips the heavy prompt composition.
|
|
730
|
+
*
|
|
731
|
+
* Returns { state: 'fresh' | 'drift_detected' | 'needs_breakdown', changed_count? }
|
|
732
|
+
* or null when there is no focus pin to gate against.
|
|
733
|
+
*/
|
|
734
|
+
export function probeAlignmentState(vaultRoot, args = {}) {
|
|
735
|
+
if (args.ignore_focus === true || args.ignore_focus === 'true') return null;
|
|
736
|
+
|
|
737
|
+
const focus = loadFocusOverride(vaultRoot, {
|
|
738
|
+
neonPinSpecId: args.neon_pin_spec_id,
|
|
739
|
+
neonSelectionSpecPath: args.neon_selection_spec_path,
|
|
740
|
+
});
|
|
741
|
+
if (!focus || !focus.spec_path) return null;
|
|
742
|
+
const focusSpec = loadSpec(vaultRoot, focus.spec_path);
|
|
743
|
+
if (!focusSpec) return null;
|
|
744
|
+
|
|
745
|
+
const focusFm = focusSpec.frontmatter || {};
|
|
746
|
+
const status = (focusFm.status || '').toLowerCase();
|
|
747
|
+
if (ALIGNMENT_SKIP_STATUSES.has(status)) return null;
|
|
748
|
+
|
|
749
|
+
const loadRef = (ref) => {
|
|
750
|
+
try {
|
|
751
|
+
return loadTask(vaultRoot, ref);
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
// A task pin probes its parent epic — same rule as the gate itself, so the
|
|
757
|
+
// banner and the gate can never disagree about whether drift exists.
|
|
758
|
+
const target = resolveAlignmentGateTarget(vaultRoot, focus, focusSpec, { loadRef });
|
|
759
|
+
if (!target) return null;
|
|
760
|
+
const { grain, fm } = target;
|
|
761
|
+
|
|
762
|
+
const childRefs = getChildRefs(fm, grain);
|
|
763
|
+
const childCount = childRefs.reduce((n, ref) => (loadRef(ref) ? n + 1 : n), 0);
|
|
764
|
+
if (childCount === 0) {
|
|
765
|
+
return { state: 'needs_breakdown', grain, focus_spec_id: fm.id || focus.spec_path };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const loadFileMtime = (ref) => {
|
|
769
|
+
try {
|
|
770
|
+
const p = findSpecFile(vaultRoot, ref);
|
|
771
|
+
if (!p) return null;
|
|
772
|
+
const st = statSync(join(vaultRoot, p));
|
|
773
|
+
return st.mtimeMs;
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
const drift = detectCorpusDrift({
|
|
779
|
+
epic: { frontmatter: fm },
|
|
780
|
+
reviewedAt: fm.alignment_reviewed_at,
|
|
781
|
+
loadRef,
|
|
782
|
+
lastModified: loadFileMtime,
|
|
783
|
+
});
|
|
784
|
+
if (drift.hasDrift) {
|
|
785
|
+
return {
|
|
786
|
+
state: 'drift_detected',
|
|
787
|
+
grain,
|
|
788
|
+
focus_spec_id: fm.id || focus.spec_path,
|
|
789
|
+
changed_count: Array.isArray(drift.stale) ? drift.stale.length : 0,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return { state: 'fresh', grain, focus_spec_id: fm.id || focus.spec_path };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Fetch the WIP ledger from the console (`GET /api/wip`) when this machine's
|
|
797
|
+
* dev-mode reliance is CLOUD — there the hand-off landed in the Neon
|
|
798
|
+
* `wip_ledger` table ([[HS-VOCF]]: the deployed console cannot write
|
|
799
|
+
* `.heyspex/wip-state.json`, so the local file is stale by design) and the
|
|
800
|
+
* /hs-next brief must surface the Neon row, not the file. Returns the
|
|
801
|
+
* `{ [specId]: entry }` map, or null when reliance is local / dev-mode cannot
|
|
802
|
+
* be read / the console is unreachable — every failure falls back to the
|
|
803
|
+
* file-ledger path in {@link enrichOnDeckWithWip}, best-effort, never throws.
|
|
804
|
+
*/
|
|
805
|
+
export async function fetchCloudWipStateForBrief(vaultRoot) {
|
|
806
|
+
try {
|
|
807
|
+
// Bundler-safe loader: the two runtime paths first (stdio behaviour is
|
|
808
|
+
// unchanged), then a static specifier webpack can trace — without it the
|
|
809
|
+
// console's `POST /api/mcp` build baked the BUILD machine's checkout path,
|
|
810
|
+
// missed on Vercel, and every remote /hs-next silently lost its WIP block.
|
|
811
|
+
const mod = await importDevModeCore([
|
|
812
|
+
join(CHECKOUT_ROOT, 'scripts', 'lib', 'dev-mode.mjs'),
|
|
813
|
+
vaultRoot ? join(vaultRoot, 'scripts', 'lib', 'dev-mode.mjs') : null,
|
|
814
|
+
]);
|
|
815
|
+
const resolved = mod.resolveDevMode();
|
|
816
|
+
if (resolved?.reliance !== 'cloud') return null;
|
|
817
|
+
} catch {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
const res = await consoleFetch('/api/wip', { timeout: 10000 });
|
|
822
|
+
// A denied caller scope is a 200 with an EMPTY ledger and an
|
|
823
|
+
// authorized:false marker (review S2) - treat it as "cloud unavailable"
|
|
824
|
+
// (null -> file fallback), never as "there is no hand-off".
|
|
825
|
+
if (res?.ok && res.body?.vaultScope?.authorized === false) return null;
|
|
826
|
+
const state = res?.ok ? res.body?.wip_state : null;
|
|
827
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return null;
|
|
828
|
+
return state;
|
|
829
|
+
} catch {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Best-effort injection of the cloud ledger into next-on-deck args as
|
|
836
|
+
* `cloud_wip_state` (internal arg, not in the tool schema — same pattern as
|
|
837
|
+
* `neon_pin_spec_id`). No-op outside cloud reliance and in summary mode
|
|
838
|
+
* (the summary enricher chain skips the WIP block entirely). Exported for
|
|
839
|
+
* the `hs next` CLI face (`scripts/hs-next.mjs`), which builds the same brief.
|
|
840
|
+
*/
|
|
841
|
+
export async function injectCloudWipState(vaultRoot, args) {
|
|
842
|
+
if (args?.summary === true || args?.summary === 'true') return args;
|
|
843
|
+
if (args?.ignore_wip === true || args?.ignore_wip === 'true') return args;
|
|
844
|
+
const cloud = await fetchCloudWipStateForBrief(vaultRoot);
|
|
845
|
+
return cloud ? { ...args, cloud_wip_state: cloud } : args;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Read `.heyspex/wip-state.json` from disk. {} on missing / parse error. */
|
|
849
|
+
export function loadWipState(vaultRoot) {
|
|
850
|
+
try {
|
|
851
|
+
const p = join(vaultRoot, '.heyspex', 'wip-state.json');
|
|
852
|
+
if (!existsSync(p)) return {};
|
|
853
|
+
const parsed = JSON.parse(readFileSync(p, 'utf-8'));
|
|
854
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
855
|
+
return parsed;
|
|
856
|
+
} catch {
|
|
857
|
+
return {};
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function renderWipBlock(specId, entry) {
|
|
862
|
+
const lines = [
|
|
863
|
+
`## 📋 Previous session WIP — ${specId} (read this, don't reconstruct)`,
|
|
864
|
+
'',
|
|
865
|
+
`> A prior session left work on this epic. This is the machine-readable handoff — use it instead of grepping wiring or \`git diff\` to rediscover state. Recorded ${entry.updatedAt || 'unknown'}${entry.agent ? ` by ${entry.agent}` : ''}.`,
|
|
866
|
+
'',
|
|
867
|
+
];
|
|
868
|
+
const list = (label, arr) => {
|
|
869
|
+
if (!Array.isArray(arr) || arr.length === 0) return;
|
|
870
|
+
lines.push(`**${label}:**`);
|
|
871
|
+
for (const item of arr.slice(0, 25)) lines.push(`- ${item}`);
|
|
872
|
+
if (arr.length > 25) lines.push(`- …(${arr.length - 25} more)`);
|
|
873
|
+
lines.push('');
|
|
874
|
+
};
|
|
875
|
+
list('Untracked artifacts (built, not committed)', entry.untrackedArtifacts);
|
|
876
|
+
list('Files touched (uncommitted)', entry.filesTouched);
|
|
877
|
+
list('Specs filed', entry.specsFiled);
|
|
878
|
+
list('Verified', entry.verified);
|
|
879
|
+
list('Remaining', entry.remaining);
|
|
880
|
+
if (entry.notes) {
|
|
881
|
+
lines.push(`**Notes:** ${entry.notes}`, '');
|
|
882
|
+
}
|
|
883
|
+
return lines.join('\n');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export function wipEntryIsEmpty(entry) {
|
|
887
|
+
if (!entry || typeof entry !== 'object') return true;
|
|
888
|
+
const arrays = ['untrackedArtifacts', 'filesTouched', 'specsFiled', 'verified', 'remaining'];
|
|
889
|
+
const hasArray = arrays.some((k) => Array.isArray(entry[k]) && entry[k].length > 0);
|
|
890
|
+
return !hasArray && !entry.notes;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Statuses whose WIP entry the ledger keeps. Wider than plain {open, in_progress}:
|
|
895
|
+
* in_review / review_pending are build-complete but not done-with-handoff — review
|
|
896
|
+
* is a non-gating, deferrable suggestion (mvp-review-is-optional, owner correction
|
|
897
|
+
* 2026-08-15), so an epic can keep collecting WIP after submit_for_review. Mirrors
|
|
898
|
+
* the console write-side retention in `spex-console/src/lib/wip-state.ts`
|
|
899
|
+
* ([[HS-0QFI-AST6]]) — keep both in sync.
|
|
900
|
+
*/
|
|
901
|
+
export const WIP_RETAIN_STATUSES = new Set(['open', 'in_progress', 'in_review', 'review_pending']);
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Does this epic still have an in-flight handoff worth surfacing? Fail-safe: a
|
|
905
|
+
* missing spec or read error returns true (surface), so a transient lookup
|
|
906
|
+
* failure never hides live WIP — the console write-side sweep is the
|
|
907
|
+
* authoritative deletion path.
|
|
908
|
+
*/
|
|
909
|
+
export function wipEpicIsOpen(vaultRoot, epicId) {
|
|
910
|
+
try {
|
|
911
|
+
const specPath = findSpecFile(vaultRoot, epicId);
|
|
912
|
+
if (!specPath) return true;
|
|
913
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
914
|
+
const status = spec?.frontmatter?.status;
|
|
915
|
+
if (typeof status !== 'string' || !status) return true;
|
|
916
|
+
return WIP_RETAIN_STATUSES.has(status);
|
|
917
|
+
} catch {
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Surface the per-epic WIP ledger ([[HS-EPIC-FCSM]]) in the on-deck brief. When
|
|
924
|
+
* the result belongs to an epic that has a recorded WIP entry, prepend a
|
|
925
|
+
* "Previous session WIP" block so the next agent reads structured state instead
|
|
926
|
+
* of reconstructing it from specs + git diff. Display-only — never gates.
|
|
927
|
+
*/
|
|
928
|
+
export function enrichOnDeckWithWip(vaultRoot, result, args = {}) {
|
|
929
|
+
if (!result || result.error) return result;
|
|
930
|
+
if (args.ignore_wip === true || args.ignore_wip === 'true') return result;
|
|
931
|
+
|
|
932
|
+
// Cloud reliance: the hand-off lives in the Neon `wip_ledger` table and was
|
|
933
|
+
// pre-fetched by `injectCloudWipState` ([[HS-VOCF]]); the local file is stale
|
|
934
|
+
// by design there. Absent (local reliance, console unreachable) → the file.
|
|
935
|
+
const cloudState =
|
|
936
|
+
args.cloud_wip_state &&
|
|
937
|
+
typeof args.cloud_wip_state === 'object' &&
|
|
938
|
+
!Array.isArray(args.cloud_wip_state)
|
|
939
|
+
? args.cloud_wip_state
|
|
940
|
+
: null;
|
|
941
|
+
const state = cloudState ?? loadWipState(vaultRoot);
|
|
942
|
+
if (!state || Object.keys(state).length === 0) return result;
|
|
943
|
+
|
|
944
|
+
// Candidate epic ids the result could belong to, most-specific first. Includes
|
|
945
|
+
// the focus-pinned epic so a freshly-claimed epic surfaces its prior WIP even
|
|
946
|
+
// before the resolver drills into a child task.
|
|
947
|
+
const focus = loadFocusOverride(vaultRoot, {
|
|
948
|
+
neonPinSpecId: args.neon_pin_spec_id,
|
|
949
|
+
neonSelectionSpecPath: args.neon_selection_spec_path,
|
|
950
|
+
});
|
|
951
|
+
const focusSpec = focus?.spec_path ? loadSpec(vaultRoot, focus.spec_path) : null;
|
|
952
|
+
const focusFm = focusSpec?.frontmatter || {};
|
|
953
|
+
const focusIsEpic = focusFm.type === 'epic' || focus?.kind === 'epic';
|
|
954
|
+
|
|
955
|
+
const candidates = [
|
|
956
|
+
result.pinned_epic?.id,
|
|
957
|
+
result.seed_epic?.id,
|
|
958
|
+
focusIsEpic ? (focusFm.id || focus.spec_path) : null,
|
|
959
|
+
// result.spec_id itself when the on-deck pick IS an epic (focus_override).
|
|
960
|
+
typeof result.spec_id === 'string' && parseSpecId(result.spec_id)?.typeLabel === 'EPIC'
|
|
961
|
+
? result.spec_id
|
|
962
|
+
: null,
|
|
963
|
+
].filter(Boolean);
|
|
964
|
+
|
|
965
|
+
for (const epicId of candidates) {
|
|
966
|
+
const entry = state[epicId];
|
|
967
|
+
if (entry && !wipEntryIsEmpty(entry)) {
|
|
968
|
+
// Retention guard ([[HS-0QFI-AST6]]): the ledger holds open/in_progress/
|
|
969
|
+
// in_review/review_pending epics. If this epic has since gone terminal or
|
|
970
|
+
// paused, don't surface its WIP — the work isn't in flight. The console
|
|
971
|
+
// write-side sweep deletes the stale entry on the next record; this
|
|
972
|
+
// read-side skip covers the window until then.
|
|
973
|
+
if (!wipEpicIsOpen(vaultRoot, epicId)) continue;
|
|
974
|
+
const block = renderWipBlock(epicId, entry);
|
|
975
|
+
return {
|
|
976
|
+
...result,
|
|
977
|
+
prompt_markdown: `${block}\n\n---\n\n${result.prompt_markdown || ''}`,
|
|
978
|
+
wip_state: { epic_id: epicId, ...entry },
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* The claim target's id in long display form — `HS-<fileKey>-<familyKey>`,
|
|
987
|
+
* without the TYPE label because every surface that renders it also renders
|
|
988
|
+
* `level` in adjacent prose ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]).
|
|
989
|
+
* Reads the family key off the leaf's own path (era-3 basenames carry it
|
|
990
|
+
* positionally) and degrades to the short id when the path is absent.
|
|
991
|
+
*/
|
|
992
|
+
export function displayClaimTargetId(claimTarget = {}) {
|
|
993
|
+
const relPath = claimTarget.relPath || null;
|
|
994
|
+
const basename = relPath ? String(relPath).split(/[\\/]/).pop().replace(/\.md$/, '') : null;
|
|
995
|
+
return formatDisplayId(claimTarget.spec_id, { basename, type: claimTarget.level });
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Render the ONE `**Sprint:**` line for the claim-target banner
|
|
1000
|
+
* ([[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criterion 9 /
|
|
1001
|
+
* [[HS-ARWE-CPQD]]). `sprint` is `result.sprint` from
|
|
1002
|
+
* {@link attachSprintToOnDeck} — the shape it sets on success (never called
|
|
1003
|
+
* when `sprint` is null/absent, so this never has to render a "no sprint"
|
|
1004
|
+
* state). `selfId` (the claim target's own `spec_id`) is excluded from the
|
|
1005
|
+
* "with …" list. Tokens are rounded to the nearest k, per the epic's contract
|
|
1006
|
+
* — this is a heuristic planning aid, not an exact count.
|
|
1007
|
+
*/
|
|
1008
|
+
export function formatSprintLine(sprint, selfId) {
|
|
1009
|
+
const toK = (n) => `${Math.round((Number(n) || 0) / 1000)}k`;
|
|
1010
|
+
const budgetK = toK(sprint.budget);
|
|
1011
|
+
if (sprint.size <= 1) {
|
|
1012
|
+
if (sprint.overBudget) {
|
|
1013
|
+
return `**Bundle:** ⚠ ~${toK(sprint.estimatedTokens)} tokens exceeds the ${budgetK} budget - plan a solo session or split this task first`;
|
|
1014
|
+
}
|
|
1015
|
+
return `**Bundle:** solo · ~${toK(sprint.estimatedTokens)} of ${budgetK} tokens`;
|
|
1016
|
+
}
|
|
1017
|
+
const selfLower = String(selfId || '').toLowerCase();
|
|
1018
|
+
const others = (Array.isArray(sprint.taskIds) ? sprint.taskIds : []).filter(
|
|
1019
|
+
(id) => String(id).toLowerCase() !== selfLower,
|
|
1020
|
+
);
|
|
1021
|
+
const shown = others.slice(0, 6);
|
|
1022
|
+
const extra = others.length - shown.length;
|
|
1023
|
+
const withPart = shown.length
|
|
1024
|
+
? ` · with ${shown.join(', ')}${extra > 0 ? ` +${extra}` : ''}`
|
|
1025
|
+
: '';
|
|
1026
|
+
// "Bundle", not "sprint": owner ruling 2026-09-02 — a sprint is the roadmap
|
|
1027
|
+
// unit above the milestone (two or more milestones); the context-window task
|
|
1028
|
+
// group is a bundle ([[HS-3C9M-A4TX-CAP]], supersedes the 2026-05-29 term).
|
|
1029
|
+
return `**Bundle:** task ${sprint.position} of ${sprint.size} in bundle ${sprint.index + 1} · ~${toK(sprint.totalTokens)} of ${budgetK} tokens${withPart}`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
export function formatClaimBanner(claimTarget, alignmentResult = {}, sprint = null) {
|
|
1033
|
+
if (!claimTarget) return '';
|
|
1034
|
+
// Long display form ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]):
|
|
1035
|
+
// the family key names the milestone lineage, so the owner can reference the
|
|
1036
|
+
// parent from the banner alone. `includeType` stays off — the banner already
|
|
1037
|
+
// renders `(${level})` and the claim question already bolds the level, so
|
|
1038
|
+
// suffixing `-TASK` would state the kind twice. Display only: every claim,
|
|
1039
|
+
// tool call, and API write still uses the short `claimTarget.spec_id`.
|
|
1040
|
+
const displayedId = displayClaimTargetId(claimTarget);
|
|
1041
|
+
const breadcrumb =
|
|
1042
|
+
Array.isArray(claimTarget.hierarchy_breadcrumb) && claimTarget.hierarchy_breadcrumb.length
|
|
1043
|
+
? claimTarget.hierarchy_breadcrumb.join(' › ')
|
|
1044
|
+
: displayedId;
|
|
1045
|
+
const lines = [
|
|
1046
|
+
`### 🎯 Claim target — \`${displayedId}\` (${claimTarget.level})`,
|
|
1047
|
+
'',
|
|
1048
|
+
`**Breadcrumb:** ${breadcrumb}`,
|
|
1049
|
+
`**Title:** ${claimTarget.title || displayedId}`,
|
|
1050
|
+
];
|
|
1051
|
+
// Surface the leaf's authored plain-language summary so the user recognizes the
|
|
1052
|
+
// work before authorizing the claim. The value is a YAML block scalar on disk
|
|
1053
|
+
// (contract-capped ~400 chars); collapse its newlines for the one-line banner.
|
|
1054
|
+
// When absent, omit the line — composeAgentOrchestration handles the
|
|
1055
|
+
// missing-summary case in the claim question (author-then-claim fallback).
|
|
1056
|
+
if (claimTarget.summary && String(claimTarget.summary).trim()) {
|
|
1057
|
+
const summaryOneLine = String(claimTarget.summary).replace(/\s+/g, ' ').trim();
|
|
1058
|
+
lines.push(`**Summary:** ${summaryOneLine}`);
|
|
1059
|
+
}
|
|
1060
|
+
lines.push(
|
|
1061
|
+
`**Intent:** ${claimTarget.intent_verb}`,
|
|
1062
|
+
`**Alignment:** \`${claimTarget.alignment_state || alignmentResult.state || 'unknown'}\``,
|
|
1063
|
+
);
|
|
1064
|
+
// Sprint (token-budgeted work-group), computed at the planning gate and
|
|
1065
|
+
// surfaced here BEFORE execution begins — never decided by the implementing
|
|
1066
|
+
// agent, never silently re-derived mid-implementation
|
|
1067
|
+
// ([[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
|
|
1068
|
+
// 7-9 / [[HS-ARWE-CPQD]]). `sprint` arrives only via a second
|
|
1069
|
+
// `formatClaimBanner` call from `attachSprintToOnDeck` once the async
|
|
1070
|
+
// console fetch resolves — the FIRST (synchronous) call in
|
|
1071
|
+
// `enrichOnDeckWithClaimTarget` always passes `null` here.
|
|
1072
|
+
if (sprint) {
|
|
1073
|
+
lines.push(formatSprintLine(sprint, claimTarget.spec_id));
|
|
1074
|
+
}
|
|
1075
|
+
if ((claimTarget.alignment_state || alignmentResult.state) === 'drift_detected') {
|
|
1076
|
+
lines.push(
|
|
1077
|
+
'',
|
|
1078
|
+
'> ⚠️ Drift detected. Review the brief sections above before authorizing — pick option [1] proceed only after you have answered keep/update/drop on each stale corpus doc.',
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
if (claimTarget.rollup_state === 'reviewer_blocked') {
|
|
1082
|
+
const n = claimTarget.rollup_detail?.in_review ?? 'some';
|
|
1083
|
+
lines.push(
|
|
1084
|
+
'',
|
|
1085
|
+
`⏳ **Reviewer-blocked** — ${n} child task(s) in review awaiting human review. The right next actor is the reviewer (walk each child's HS-REV companion, accept/reject memory candidates, commit to close).`,
|
|
1086
|
+
);
|
|
1087
|
+
} else if (claimTarget.rollup_state === 'ready_for_close') {
|
|
1088
|
+
lines.push(
|
|
1089
|
+
'',
|
|
1090
|
+
'📋 **Ready for close-out** — every child is build-complete (closed or awaiting batched review below the active review grain). Flip this unit to `in_review`, add the Review back-link, and file an HS-REV companion. See [[session-close-protocol]].',
|
|
1091
|
+
);
|
|
1092
|
+
} else if (claimTarget.rollup_state === 'terminal_leaf') {
|
|
1093
|
+
if (claimTarget.next_action_hint === 'wait_for_reviewer') {
|
|
1094
|
+
lines.push(
|
|
1095
|
+
'',
|
|
1096
|
+
'⏳ **Awaiting review** — this task is done from the agent\'s perspective. Walk its HS-REV companion (or run `/hs-next review <task-id>`); the human reviewer commits to close.',
|
|
1097
|
+
);
|
|
1098
|
+
} else {
|
|
1099
|
+
const st = claimTarget.terminal_status || 'closed';
|
|
1100
|
+
lines.push(
|
|
1101
|
+
'',
|
|
1102
|
+
`✅ **Already terminal** — status is \`${st}\`. There is no implementation work to claim.`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return lines.join('\n');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Parent-contract tethering ([[HS-FRGG-IU2S-TASK-parent-contract-tethering-in-the-hs]]):
|
|
1110
|
+
// a contract-shaped link is a `related:` ref that resolves under specs/adr/,
|
|
1111
|
+
// specs/protocols/, or to the entity model — the docs a task could drift against.
|
|
1112
|
+
export const CONTRACT_PATH_RE = /(^|[\\/])(adr|protocols)[\\/]|(^|[\\/])entity-model(\.md)?$/i;
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Filter a list of refs (wikilinks or bare basenames) down to contract-shaped
|
|
1116
|
+
* docs. Pure: link resolution is injected as `resolveRelPath(ref) → relPath|null`.
|
|
1117
|
+
* Dedupes by ref; a ref that fails to resolve is silently dropped (it is a
|
|
1118
|
+
* dangling link, the linkage gate's problem — not this brief's).
|
|
1119
|
+
*/
|
|
1120
|
+
export function classifyContractRefs(refs, resolveRelPath) {
|
|
1121
|
+
const out = [];
|
|
1122
|
+
const seen = new Set();
|
|
1123
|
+
for (const raw of Array.isArray(refs) ? refs : []) {
|
|
1124
|
+
const ref = String(raw ?? '')
|
|
1125
|
+
.replace(/^\[\[/, '')
|
|
1126
|
+
.replace(/\]\]$/, '')
|
|
1127
|
+
.split('|')[0]
|
|
1128
|
+
.split('#')[0]
|
|
1129
|
+
.trim();
|
|
1130
|
+
if (!ref || seen.has(ref)) continue;
|
|
1131
|
+
seen.add(ref);
|
|
1132
|
+
let relPath = null;
|
|
1133
|
+
try {
|
|
1134
|
+
relPath = resolveRelPath ? resolveRelPath(ref) : null;
|
|
1135
|
+
} catch {
|
|
1136
|
+
relPath = null;
|
|
1137
|
+
}
|
|
1138
|
+
if (relPath && CONTRACT_PATH_RE.test(String(relPath))) {
|
|
1139
|
+
out.push({ ref, relPath: String(relPath).replace(/\\/g, '/') });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Render the task-grain "Parent contract" brief section: the parent epic's goal
|
|
1147
|
+
* plus the contract docs the task must load — or, when no contract-shaped link
|
|
1148
|
+
* resolves, an EXPLICIT hard-surface line (never a silent omission — the epic's
|
|
1149
|
+
* "warning at file time, hard surface at claim time" rule).
|
|
1150
|
+
*/
|
|
1151
|
+
export function composeParentContractSection(claimTarget = {}) {
|
|
1152
|
+
if (!claimTarget || claimTarget.level !== 'task') return '';
|
|
1153
|
+
const parent = claimTarget.parent || null;
|
|
1154
|
+
const contracts = Array.isArray(claimTarget.parent_contracts)
|
|
1155
|
+
? claimTarget.parent_contracts
|
|
1156
|
+
: [];
|
|
1157
|
+
const lines = ['#### 📜 Parent contract'];
|
|
1158
|
+
if (parent && (parent.goal || parent.title)) {
|
|
1159
|
+
const goal = String(parent.goal || '').replace(/\s+/g, ' ').trim();
|
|
1160
|
+
lines.push(
|
|
1161
|
+
'',
|
|
1162
|
+
`**Parent${parent.kind ? ` ${parent.kind}` : ''}${parent.title ? ` — ${parent.title}` : ''}:** ${
|
|
1163
|
+
goal || 'no goal recorded — read the parent spec before starting'
|
|
1164
|
+
}`,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
if (contracts.length) {
|
|
1168
|
+
lines.push('', '**Contracts to honor (load before editing the interfaces they own):**');
|
|
1169
|
+
for (const c of contracts) {
|
|
1170
|
+
lines.push(`- [[${c.ref}]] — \`${c.relPath}\``);
|
|
1171
|
+
}
|
|
1172
|
+
} else {
|
|
1173
|
+
lines.push(
|
|
1174
|
+
'',
|
|
1175
|
+
'⚠ **No contract link — parent contract not tethered.** Neither this task nor its parent links an ADR, protocol, or the entity model. Ground against the parent spec before touching shared interfaces, and add the missing contract link to the task.',
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
return lines.join('\n');
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
export function deriveAlignmentState(result) {
|
|
1182
|
+
if (!result) return 'unknown';
|
|
1183
|
+
if (result.alignment_drift) return 'drift_detected';
|
|
1184
|
+
if (typeof result.source_kind === 'string') {
|
|
1185
|
+
if (result.source_kind.endsWith('_gate')) return 'drift_detected';
|
|
1186
|
+
if (result.source_kind === 'milestone_needs_breakdown') return 'drift_detected';
|
|
1187
|
+
}
|
|
1188
|
+
if (typeof result.status === 'string') {
|
|
1189
|
+
if (result.status === 'needs_breakdown' || result.status === 'alignment_review') {
|
|
1190
|
+
return 'drift_detected';
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return 'fresh';
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Bookend the response with a claim-target banner so the user reads hierarchy
|
|
1198
|
+
* + intent before reaching the claim line. Reads spec via loadTask — disk,
|
|
1199
|
+
* or (cloud tier) `args.__cloud_task_graph`'s pre-fetch — never from a
|
|
1200
|
+
* derived index (SSOT). Best-effort: on failure the banner is skipped and
|
|
1201
|
+
* the original prompt_markdown stays intact.
|
|
1202
|
+
*/
|
|
1203
|
+
export function enrichOnDeckWithClaimTarget(vaultRoot, result, args = {}) {
|
|
1204
|
+
if (!result || result.error) return result;
|
|
1205
|
+
if (!result.spec_id) return result;
|
|
1206
|
+
if (args.ignore_claim_target === true || args.ignore_claim_target === 'true') return result;
|
|
1207
|
+
|
|
1208
|
+
// [[HS-H227-MCPC-TASK]]: `__cloud_task_graph` (pre-fetched over HTTP by
|
|
1209
|
+
// `handleNextOnDeck` when cloud-reliant) routes both this load and the
|
|
1210
|
+
// `loadRef` hierarchy/parent-contract walk below through
|
|
1211
|
+
// `loadTaskViaGraph` instead of straight-to-disk `loadTask`. Undefined
|
|
1212
|
+
// (every disk-reliant call site) falls through to the original disk read.
|
|
1213
|
+
let spec = null;
|
|
1214
|
+
try {
|
|
1215
|
+
spec = loadTaskViaGraph(vaultRoot, result.spec_id, args.__cloud_task_graph);
|
|
1216
|
+
} catch {
|
|
1217
|
+
spec = null;
|
|
1218
|
+
}
|
|
1219
|
+
if (!spec) return result;
|
|
1220
|
+
|
|
1221
|
+
const loadRef = (ref) => {
|
|
1222
|
+
try {
|
|
1223
|
+
return loadTaskViaGraph(vaultRoot, ref, args.__cloud_task_graph);
|
|
1224
|
+
} catch {
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
const alignmentState = deriveAlignmentState(result);
|
|
1229
|
+
// Converge the claim target onto scheduler output ([[HS-EPIC-CNVG]]): order
|
|
1230
|
+
// the milestone/epic descent by the same global scheduler order every display
|
|
1231
|
+
// surface reads. Priority: the global cache → else the already-scheduler-sorted
|
|
1232
|
+
// `candidate_epics` (milestone drill, warm even on cold cache) → else declared
|
|
1233
|
+
// order. Without this the walk picks the first *written* child (the
|
|
1234
|
+
// seed_epic-vs-claim_target divergence this fixes).
|
|
1235
|
+
const cacheOrderIds = readSchedulerCacheOrderIds(vaultRoot);
|
|
1236
|
+
const schedulerOrderIds =
|
|
1237
|
+
cacheOrderIds.length > 0
|
|
1238
|
+
? cacheOrderIds
|
|
1239
|
+
: Array.isArray(result.candidate_epics)
|
|
1240
|
+
? result.candidate_epics.map((e) => e?.id).filter(Boolean)
|
|
1241
|
+
: [];
|
|
1242
|
+
// Tasks other agents actively hold ([[HS-3C9M-A4TX-CAP]]) — set by
|
|
1243
|
+
// resolveNextOnDeckAroundActiveClaims so the walk offers the next task
|
|
1244
|
+
// nobody holds instead of the one already being worked.
|
|
1245
|
+
const excludeIds = Array.isArray(args.exclude_spec_ids)
|
|
1246
|
+
? new Set(args.exclude_spec_ids.map((id) => String(id).toLowerCase()))
|
|
1247
|
+
: null;
|
|
1248
|
+
const claimTarget = computeClaimTarget(
|
|
1249
|
+
spec,
|
|
1250
|
+
vaultRoot,
|
|
1251
|
+
alignmentState,
|
|
1252
|
+
loadRef,
|
|
1253
|
+
schedulerOrderIds,
|
|
1254
|
+
undefined,
|
|
1255
|
+
excludeIds && excludeIds.size > 0 ? excludeIds : null,
|
|
1256
|
+
);
|
|
1257
|
+
if (!claimTarget) return result;
|
|
1258
|
+
|
|
1259
|
+
// [[HS-68XO-DIAG]] residual — make the claim target addressable by a UNIQUELY-
|
|
1260
|
+
// resolvable handle (see disambiguateClaimSpecId).
|
|
1261
|
+
claimTarget.spec_id = disambiguateClaimSpecId(
|
|
1262
|
+
vaultRoot,
|
|
1263
|
+
claimTarget.spec_id,
|
|
1264
|
+
claimTarget.relPath,
|
|
1265
|
+
);
|
|
1266
|
+
|
|
1267
|
+
// Resolve the immediate parent's goal so the missing-summary authoring prompt
|
|
1268
|
+
// (composeAgentOrchestration, below) can lead with it — a task's epic, an
|
|
1269
|
+
// epic's milestone. Best-effort and read-from-disk (SSOT): a top-level
|
|
1270
|
+
// milestone has no parent and keeps the standalone summary contract.
|
|
1271
|
+
let parentSpecLoaded = null;
|
|
1272
|
+
try {
|
|
1273
|
+
const crumb = Array.isArray(claimTarget.hierarchy_breadcrumb)
|
|
1274
|
+
? claimTarget.hierarchy_breadcrumb
|
|
1275
|
+
: [];
|
|
1276
|
+
const parentId = crumb.length >= 2 ? crumb[crumb.length - 2] : null;
|
|
1277
|
+
if (parentId && parentId !== claimTarget.spec_id) {
|
|
1278
|
+
const parentSpec = loadRef(parentId);
|
|
1279
|
+
parentSpecLoaded = parentSpec || null;
|
|
1280
|
+
claimTarget.parent = parentSpec
|
|
1281
|
+
? {
|
|
1282
|
+
id: parentSpec.id || parentId,
|
|
1283
|
+
kind: parentSpec.type || parentSpec.frontmatter?.type || null,
|
|
1284
|
+
title: parentSpec.name || parentSpec.frontmatter?.name || null,
|
|
1285
|
+
goal: parentSpec.frontmatter?.summary || null,
|
|
1286
|
+
relPath: parentSpec.specPath || null,
|
|
1287
|
+
}
|
|
1288
|
+
: { id: parentId, kind: null, title: null, goal: null, relPath: null };
|
|
1289
|
+
}
|
|
1290
|
+
} catch {
|
|
1291
|
+
/* parent context is a nicety, not a gate — skip on any failure */
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Parent-contract tethering ([[HS-FRGG-IU2S-TASK-parent-contract-tethering-in-the-hs]]):
|
|
1295
|
+
// at task grain, collect contract-shaped `related:` links from the task AND its
|
|
1296
|
+
// parent so the brief carries the interfaces the work could drift against —
|
|
1297
|
+
// enforced-at-execution, not just recorded in the graph. Best-effort resolve;
|
|
1298
|
+
// an empty result still hard-surfaces via composeParentContractSection.
|
|
1299
|
+
if (claimTarget.level === 'task') {
|
|
1300
|
+
try {
|
|
1301
|
+
const leafSpec = loadRef(claimTarget.spec_id) || spec;
|
|
1302
|
+
const refs = [
|
|
1303
|
+
...(Array.isArray(leafSpec?.frontmatter?.related) ? leafSpec.frontmatter.related : []),
|
|
1304
|
+
...(Array.isArray(parentSpecLoaded?.frontmatter?.related)
|
|
1305
|
+
? parentSpecLoaded.frontmatter.related
|
|
1306
|
+
: []),
|
|
1307
|
+
];
|
|
1308
|
+
claimTarget.parent_contracts = classifyContractRefs(refs, (ref) => {
|
|
1309
|
+
// findSpecFile returns a VAULT-RELATIVE path (resolveSpecFileRaw contract).
|
|
1310
|
+
// It does not search specs/protocols/ — check that bucket explicitly so
|
|
1311
|
+
// protocol wikilinks (the most common contract shape) classify.
|
|
1312
|
+
const hit = findSpecFile(vaultRoot, ref);
|
|
1313
|
+
if (hit) return hit;
|
|
1314
|
+
const protoRel = join('specs', 'protocols', `${ref}.md`);
|
|
1315
|
+
return existsSync(join(vaultRoot, protoRel)) ? protoRel : null;
|
|
1316
|
+
});
|
|
1317
|
+
claimTarget.contract_tethered = claimTarget.parent_contracts.length > 0;
|
|
1318
|
+
} catch {
|
|
1319
|
+
claimTarget.parent_contracts = [];
|
|
1320
|
+
claimTarget.contract_tethered = false;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const banner = formatClaimBanner(claimTarget, { state: alignmentState });
|
|
1325
|
+
if (!banner) return result;
|
|
1326
|
+
|
|
1327
|
+
const contractSection = composeParentContractSection(claimTarget);
|
|
1328
|
+
const body = (result.prompt_markdown || '').trim();
|
|
1329
|
+
// Summary-mode path: no body to bookend — emit the banner once so the
|
|
1330
|
+
// claim-target line stands alone instead of wrapping an empty middle.
|
|
1331
|
+
const prompt = body.length === 0
|
|
1332
|
+
? (contractSection ? `${banner}\n\n${contractSection}` : banner)
|
|
1333
|
+
: `${banner}\n\n${contractSection ? `${contractSection}\n\n` : ''}---\n\n${result.prompt_markdown}\n\n---\n\n${banner}`;
|
|
1334
|
+
const enriched = {
|
|
1335
|
+
...result,
|
|
1336
|
+
// user_prompt_markdown is the canonical user-facing field after HS-TASK-HSNX
|
|
1337
|
+
// split it from agent_dispatch. prompt_markdown stays as the @deprecated
|
|
1338
|
+
// mirror so unmigrated consumers print the same user-facing content.
|
|
1339
|
+
user_prompt_markdown: prompt,
|
|
1340
|
+
prompt_markdown: prompt,
|
|
1341
|
+
claim_target: claimTarget,
|
|
1342
|
+
alignment_result: { state: alignmentState },
|
|
1343
|
+
};
|
|
1344
|
+
if (claimTarget.next_action_hint) {
|
|
1345
|
+
enriched.next_action_hint = claimTarget.next_action_hint;
|
|
1346
|
+
}
|
|
1347
|
+
return enriched;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** @returns {'handheld' | 'assisted' | 'autonomous'} */
|
|
1351
|
+
export function resolveOnDeckFront(args = {}) {
|
|
1352
|
+
return normalizeRunFrontToken(args.front) ?? 'handheld';
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** @param {Record<string, unknown>} args @param {Record<string, unknown>} [overrides] */
|
|
1356
|
+
export function packNextOnDeckToolArgs(args = {}, overrides = {}) {
|
|
1357
|
+
const packed = { ...overrides };
|
|
1358
|
+
const front = resolveOnDeckFront(args);
|
|
1359
|
+
if (front !== 'handheld') packed.front = front;
|
|
1360
|
+
if (args.spec_id && packed.spec_id === undefined) packed.spec_id = args.spec_id;
|
|
1361
|
+
if (shouldAutoClaimOnDeck(args) && packed.auto_claim === undefined) packed.auto_claim = true;
|
|
1362
|
+
return packed;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* @param {object} p
|
|
1367
|
+
* @param {string} p.specId
|
|
1368
|
+
* @param {string} p.level
|
|
1369
|
+
* @param {string} p.launchTool
|
|
1370
|
+
* @param {string} p.shortSession
|
|
1371
|
+
* @param {Record<string, unknown>} p.args
|
|
1372
|
+
* @param {Record<string, unknown>} p.failureModes
|
|
1373
|
+
*/
|
|
1374
|
+
export function buildAutoClaimOrchestration({
|
|
1375
|
+
specId,
|
|
1376
|
+
level,
|
|
1377
|
+
launchTool,
|
|
1378
|
+
shortSession,
|
|
1379
|
+
args,
|
|
1380
|
+
failureModes,
|
|
1381
|
+
}) {
|
|
1382
|
+
return {
|
|
1383
|
+
user_prompt: [
|
|
1384
|
+
'',
|
|
1385
|
+
'---',
|
|
1386
|
+
'',
|
|
1387
|
+
`🔓 **Auto-claim** — claiming **${level}** \`${specId}\` as **${launchTool} · \`${shortSession}\`** and beginning execution.`,
|
|
1388
|
+
].join('\n'),
|
|
1389
|
+
agent_dispatch: {
|
|
1390
|
+
phase: 'auto_claim_execute',
|
|
1391
|
+
execute_instruction:
|
|
1392
|
+
'Output user_prompt_markdown verbatim, then immediately run every on_complete action in the SAME turn — claim via heyspex_claim_spec, re-fetch the full brief with summary:false, and begin executing. Do NOT ask the human to confirm.',
|
|
1393
|
+
on_complete: [
|
|
1394
|
+
{
|
|
1395
|
+
tool: 'mcp__heyspex__heyspex_claim_spec',
|
|
1396
|
+
args: { spec_id: specId },
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1400
|
+
args: packNextOnDeckToolArgs(args, { summary: false }),
|
|
1401
|
+
},
|
|
1402
|
+
],
|
|
1403
|
+
on_handoff: [
|
|
1404
|
+
{
|
|
1405
|
+
tool: 'mcp__heyspex__heyspex_release_spec',
|
|
1406
|
+
args: { spec_id: specId },
|
|
1407
|
+
comment: 'On session hand-off or when moving the spec to in_review.',
|
|
1408
|
+
},
|
|
1409
|
+
],
|
|
1410
|
+
failure_modes: failureModes,
|
|
1411
|
+
},
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* @param {Record<string, unknown>} claimTarget
|
|
1417
|
+
* @param {Record<string, unknown>} result
|
|
1418
|
+
*/
|
|
1419
|
+
export function isOnDeckTaskGrain(claimTarget = {}, result = {}) {
|
|
1420
|
+
const level = String(claimTarget.level || '').toLowerCase();
|
|
1421
|
+
if (level === 'task') return true;
|
|
1422
|
+
const id = String(claimTarget.spec_id || result.spec_id || '');
|
|
1423
|
+
return /-TASK-/i.test(id);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Run-front banner for non-handheld `/hs-next` invocations.
|
|
1428
|
+
* @param {'handheld' | 'assisted' | 'autonomous'} front
|
|
1429
|
+
* @param {Record<string, unknown>} claimTarget
|
|
1430
|
+
* @param {Record<string, unknown>} result
|
|
1431
|
+
* @returns {string}
|
|
1432
|
+
*/
|
|
1433
|
+
/**
|
|
1434
|
+
* Under a run front (assisted / autonomous) the unit the dispatcher takes is a
|
|
1435
|
+
* MILESTONE. When the id the caller gave is a milestone or an epic, the brief's
|
|
1436
|
+
* job is to arm that milestone's run — not to claim the task the walk landed
|
|
1437
|
+
* on ([[HS-3C9M-A4TX-CAP]]). A task named directly keeps the task-grain rules.
|
|
1438
|
+
* Mirrors `resolveArmTarget` in scripts/hs-next.mjs.
|
|
1439
|
+
* @returns {{ milestoneId: string, from: 'milestone' | 'epic', specId: string } | null}
|
|
1440
|
+
*/
|
|
1441
|
+
export function resolveArmGrainTarget(result, front) {
|
|
1442
|
+
if (!result || (front !== 'assisted' && front !== 'autonomous')) return null;
|
|
1443
|
+
const specPath = String(result.spec_path || '').replace(/\\/g, '/');
|
|
1444
|
+
const specId = result.spec_id ? String(result.spec_id) : '';
|
|
1445
|
+
if (!specId) return null;
|
|
1446
|
+
if (/(^|\/)milestones\//.test(specPath)) return { milestoneId: specId, from: 'milestone', specId };
|
|
1447
|
+
if (/(^|\/)epics\//.test(specPath)) {
|
|
1448
|
+
const crumb = result.claim_target?.hierarchy_breadcrumb;
|
|
1449
|
+
const top = Array.isArray(crumb) && crumb.length > 1 ? String(crumb[0]) : '';
|
|
1450
|
+
return top ? { milestoneId: top, from: 'epic', specId } : null;
|
|
1451
|
+
}
|
|
1452
|
+
return null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function buildArmRunOrchestration({ arm, front, failureModes }) {
|
|
1456
|
+
const word = front === 'autonomous' ? 'auto' : 'assist';
|
|
1457
|
+
const command = `hs next ${word} ${arm.milestoneId} --launch`;
|
|
1458
|
+
const label = front === 'autonomous' ? 'Autonomous' : 'Assisted';
|
|
1459
|
+
const loop =
|
|
1460
|
+
front === 'autonomous'
|
|
1461
|
+
? 'unattended — the dispatcher applies the recommended option at each gate and you review the retrospective after'
|
|
1462
|
+
: 'you on the loop — the dispatcher pauses at each decision gate for your answer';
|
|
1463
|
+
const lines = ['', '---', '', `🏭 **${label} run front** — ${loop}.`, ''];
|
|
1464
|
+
if (arm.from !== 'milestone') {
|
|
1465
|
+
lines.push(
|
|
1466
|
+
`Runs arm at milestone grain: \`${arm.specId}\` sits under milestone \`${arm.milestoneId}\`, so that whole milestone is what gets queued.`,
|
|
1467
|
+
'',
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
lines.push(
|
|
1471
|
+
`No task is claimed here. Arm the run: \`${command}\` — it writes the same queue + front files the What's Next console writes for the running dispatcher, and prints the \`--ensure\` command when the dispatcher is not live.`,
|
|
1472
|
+
);
|
|
1473
|
+
return {
|
|
1474
|
+
user_prompt: lines.join('\n'),
|
|
1475
|
+
agent_dispatch: {
|
|
1476
|
+
phase: 'arm_run',
|
|
1477
|
+
execute_instruction:
|
|
1478
|
+
'Output user_prompt_markdown verbatim. Do NOT claim the task shown above. Give the human the arm command (it is a terminal step) and stop — the dispatcher owns execution from there.',
|
|
1479
|
+
arm_target: {
|
|
1480
|
+
milestone_id: arm.milestoneId,
|
|
1481
|
+
from: arm.from,
|
|
1482
|
+
spec_id: arm.specId,
|
|
1483
|
+
front,
|
|
1484
|
+
command,
|
|
1485
|
+
},
|
|
1486
|
+
failure_modes: failureModes,
|
|
1487
|
+
},
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export function renderRunFrontSection(front, claimTarget = {}, result = {}) {
|
|
1492
|
+
if (!front || front === 'handheld') return '';
|
|
1493
|
+
// At milestone/epic grain the orchestration section carries the arm command;
|
|
1494
|
+
// a second banner (or the "task launches handheld-only" warning) would only
|
|
1495
|
+
// contradict it.
|
|
1496
|
+
if (resolveArmGrainTarget(result, front)) return '';
|
|
1497
|
+
const specId = claimTarget.spec_id || result.spec_id || 'this spec';
|
|
1498
|
+
const atTask = isOnDeckTaskGrain(claimTarget, result);
|
|
1499
|
+
|
|
1500
|
+
if (atTask) {
|
|
1501
|
+
return [
|
|
1502
|
+
'',
|
|
1503
|
+
'---',
|
|
1504
|
+
'',
|
|
1505
|
+
`⚠️ **Run front \`${front}\` on a task** — single tasks launch in **handheld** mode only.`,
|
|
1506
|
+
'',
|
|
1507
|
+
`- To **implement** \`${specId}\`: \`/hs-next ${specId}\` (handheld is default).`,
|
|
1508
|
+
`- To **batch-run** its parent milestone (the dispatcher runs at milestone grain): \`hs next ${front === 'autonomous' ? 'auto' : 'assist'} <HS-MIL…> --launch\` at a terminal, or \`/hs-next ${front === 'autonomous' ? 'auto' : 'assist'} HS-MIL…\` here.`,
|
|
1509
|
+
`- Working **outside** the console: \`/hs-auto\` orchestrates dev-factory end-to-end.`,
|
|
1510
|
+
].join('\n');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const frontLabel = front === 'autonomous' ? 'Autonomous' : 'Assisted';
|
|
1514
|
+
const loop =
|
|
1515
|
+
front === 'autonomous'
|
|
1516
|
+
? 'you are *over* the loop — the dispatcher auto-applies Recommended options; review the Retrospective when you return'
|
|
1517
|
+
: 'you are *on* the loop — the dispatcher pauses at decision gates for live answers';
|
|
1518
|
+
|
|
1519
|
+
return [
|
|
1520
|
+
'',
|
|
1521
|
+
'---',
|
|
1522
|
+
'',
|
|
1523
|
+
`🏭 **${frontLabel} run front** — ${loop}.`,
|
|
1524
|
+
'',
|
|
1525
|
+
`- From a terminal: \`hs next ${front === 'autonomous' ? 'auto' : 'assist'} <HS-MIL…> --launch\` arms the run — it writes the same queue + front files the What's Next console writes, and several milestone ids queue in order.`,
|
|
1526
|
+
"- Or launch / steer from the What's Next console, or run `/hs-auto` when working outside HeySpex.",
|
|
1527
|
+
'- Ensure the dispatcher is live: `node dev-factory/coo-dispatcher.mjs --ensure`.',
|
|
1528
|
+
`- Scope resolved for \`${specId}\`; factory launch floor is epic/milestone grain.`,
|
|
1529
|
+
].join('\n');
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* @param {Record<string, unknown>} result
|
|
1534
|
+
* @param {Record<string, unknown>} args
|
|
1535
|
+
*/
|
|
1536
|
+
export function enrichOnDeckWithRunFront(result, args = {}) {
|
|
1537
|
+
if (!result || result.error) return result;
|
|
1538
|
+
const front = resolveOnDeckFront(args);
|
|
1539
|
+
const section = renderRunFrontSection(front, result.claim_target || {}, result);
|
|
1540
|
+
const updated = { ...result, run_front: front };
|
|
1541
|
+
if (section) {
|
|
1542
|
+
const existing = updated.user_prompt_markdown ?? updated.prompt_markdown ?? '';
|
|
1543
|
+
updated.user_prompt_markdown = `${existing}${section}`;
|
|
1544
|
+
updated.prompt_markdown = updated.user_prompt_markdown;
|
|
1545
|
+
}
|
|
1546
|
+
return updated;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Does the missing-`summary:` authoring phase have to write through the
|
|
1551
|
+
* console instead of a vault file? True when ([[HS-T41O-NZ8S-DIAG]]):
|
|
1552
|
+
* - the session's vault write mode is `cloud` (`__vault_write_mode`,
|
|
1553
|
+
* injected at the async handler boundary by `handleNextOnDeck` from
|
|
1554
|
+
* `resolveVaultWriteMode()`), or
|
|
1555
|
+
* - the target was read from the console (`__cloud_direct_spec` /
|
|
1556
|
+
* `__cloud_task_graph` — the neon read tier), or
|
|
1557
|
+
* - the target is Neon-only: its `relPath` does not exist under the
|
|
1558
|
+
* resolved vault root, so there is no file an Edit could land in.
|
|
1559
|
+
* Absent every signal (every disk-reliant call site, every caller that
|
|
1560
|
+
* predates this) the file-edit brief is kept unchanged. Pure.
|
|
1561
|
+
*/
|
|
1562
|
+
export function summaryWriteTargetsConsole(result, claimTarget = {}, args = {}) {
|
|
1563
|
+
if (args.__vault_write_mode === 'cloud') return true;
|
|
1564
|
+
if (args.__vault_write_mode === 'local') return false;
|
|
1565
|
+
if (args.__cloud_direct_spec !== undefined || args.__cloud_task_graph || args.__cloud_scheduler_rows) return true;
|
|
1566
|
+
const relPath = claimTarget?.relPath ? String(claimTarget.relPath) : '';
|
|
1567
|
+
const vaultRoot = result?.vault_root ? String(result.vault_root) : '';
|
|
1568
|
+
if (relPath && vaultRoot) {
|
|
1569
|
+
try {
|
|
1570
|
+
return !existsSync(isAbsolute(relPath) ? relPath : join(vaultRoot, relPath));
|
|
1571
|
+
} catch {
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
return false;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
export function composeAgentOrchestration(result, args = {}) {
|
|
1579
|
+
if (!result || result.error) return null;
|
|
1580
|
+
const isSummary = args.summary === true || args.summary === 'true';
|
|
1581
|
+
const claimTarget = result.claim_target || {};
|
|
1582
|
+
const alignmentState =
|
|
1583
|
+
claimTarget.alignment_state ||
|
|
1584
|
+
result.alignment_result?.state ||
|
|
1585
|
+
deriveAlignmentState(result);
|
|
1586
|
+
const specId = claimTarget.spec_id || result.spec_id || 'unknown';
|
|
1587
|
+
// Long form for the human-facing prompt text ONLY — `specId` stays short and
|
|
1588
|
+
// remains what every tool call, claim, and on_complete arg below is built
|
|
1589
|
+
// from ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]).
|
|
1590
|
+
const displaySpecId = claimTarget.spec_id ? displayClaimTargetId(claimTarget) : specId;
|
|
1591
|
+
const level = (claimTarget.level || 'spec').toUpperCase();
|
|
1592
|
+
const intent = claimTarget.intent_verb || 'work this spec';
|
|
1593
|
+
const focusSpecId =
|
|
1594
|
+
result.alignment_summary?.focus_spec_id ||
|
|
1595
|
+
result.claim_target?.spec_id ||
|
|
1596
|
+
specId;
|
|
1597
|
+
const changedCount = result.alignment_summary?.changed_count;
|
|
1598
|
+
|
|
1599
|
+
const failureModes = {
|
|
1600
|
+
CONSOLE_URL_MISMATCH: {
|
|
1601
|
+
handling: 'retry_tool_call',
|
|
1602
|
+
note: 'IF an MCP tool returns CONSOLE_URL_MISMATCH: the console dev server is UP on another port; retry the same tool — never hand-write task/epic files.',
|
|
1603
|
+
},
|
|
1604
|
+
CONSOLE_TIMEOUT: {
|
|
1605
|
+
handling: 'retry_tool_call',
|
|
1606
|
+
note: 'IF an MCP tool returns CONSOLE_TIMEOUT: the console is up but slow (cold compile). Retry shortly — do NOT tell the user the server is down.',
|
|
1607
|
+
},
|
|
1608
|
+
CONSOLE_UNREACHABLE: {
|
|
1609
|
+
handling: 'verify_with_dev_server_status',
|
|
1610
|
+
note: 'IF heyspex_claim_spec returns CONSOLE_UNREACHABLE: call heyspex_dev_server_status before telling the user the console is down. Claim-badge-only proceed (status flip on disk) — NEVER hand-write files for heyspex_file_spec failures.',
|
|
1611
|
+
},
|
|
1612
|
+
claim_blocked_by_descendants: {
|
|
1613
|
+
handling: 'rerun_with_descendant_id',
|
|
1614
|
+
note: '409 from heyspex_claim_spec ([[HS-TASK-0RVD]]); use claim_target.spec_id from the error and re-run /hs-next <that-id>',
|
|
1615
|
+
},
|
|
1616
|
+
NO_ACTIONABLE_TASK: {
|
|
1617
|
+
handling: 'suggest_include_horizon',
|
|
1618
|
+
note: 'roadmap is empty/closed; suggest /hs-next --include-horizon',
|
|
1619
|
+
},
|
|
1620
|
+
ROADMAP_NOT_FOUND: { handling: 'surface_and_stop' },
|
|
1621
|
+
VAULT_NOT_FOUND: { handling: 'surface_and_stop' },
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
if (!isSummary) {
|
|
1625
|
+
// Full mode — the user has already authorized at the prior summary step.
|
|
1626
|
+
// No new user-facing prose is added — the body + banner is already on
|
|
1627
|
+
// user_prompt_markdown. Only the structured dispatch is emitted, so the
|
|
1628
|
+
// agent can execute + handle a drift menu without printing any of it.
|
|
1629
|
+
return {
|
|
1630
|
+
user_prompt: null,
|
|
1631
|
+
agent_dispatch: {
|
|
1632
|
+
phase: 'post_claim_execute',
|
|
1633
|
+
execute_instruction:
|
|
1634
|
+
'Output user_prompt_markdown verbatim, then begin executing the work in the SAME turn. The user already authorized the claim — do not ask again.',
|
|
1635
|
+
on_handoff: [
|
|
1636
|
+
{
|
|
1637
|
+
tool: 'mcp__heyspex__heyspex_release_spec',
|
|
1638
|
+
args: { spec_id: specId },
|
|
1639
|
+
comment: 'On session hand-off or when moving the spec to in_review.',
|
|
1640
|
+
},
|
|
1641
|
+
],
|
|
1642
|
+
drift_resolution_menu: {
|
|
1643
|
+
proceed: {
|
|
1644
|
+
tokens: ['1', 'proceed', 'implement'],
|
|
1645
|
+
actions: [
|
|
1646
|
+
{
|
|
1647
|
+
tool: 'mcp__heyspex__heyspex_record_alignment',
|
|
1648
|
+
args: {
|
|
1649
|
+
spec_id: focusSpecId,
|
|
1650
|
+
override_reason:
|
|
1651
|
+
"<one-line user reason or 'drift accepted at /hs-next'>",
|
|
1652
|
+
},
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
tool: 'mcp__heyspex__heyspex_claim_spec',
|
|
1656
|
+
args: { spec_id: specId },
|
|
1657
|
+
},
|
|
1658
|
+
{ tool: 'mcp__heyspex__heyspex_next_on_deck', args: packNextOnDeckToolArgs(args) },
|
|
1659
|
+
],
|
|
1660
|
+
execute_after: true,
|
|
1661
|
+
},
|
|
1662
|
+
refresh_task: {
|
|
1663
|
+
tokens: ['2', 'refresh task'],
|
|
1664
|
+
reply:
|
|
1665
|
+
'Option 2 (`heyspex_regen_task`) is implemented under [[HS-EPIC-HSN1]] / [[HS-TASK-0CKW]]. Until it ships, pick `1` to proceed past the drift or `4` to defer.',
|
|
1666
|
+
},
|
|
1667
|
+
refresh_epic: {
|
|
1668
|
+
tokens: ['3', 'refresh epic'],
|
|
1669
|
+
reply:
|
|
1670
|
+
'Option 3 (`heyspex_regen_tasks_for_epic`) is implemented under [[HS-EPIC-HSN1]] / [[HS-TASK-0CKW]]. Until it ships, pick `1` to proceed past the drift or `4` to defer.',
|
|
1671
|
+
},
|
|
1672
|
+
defer: {
|
|
1673
|
+
tokens: ['4', 'defer', 'skip'],
|
|
1674
|
+
reply:
|
|
1675
|
+
'Deferred. Re-run `/hs-next` when ready, or re-pin `.heyspex/focus.yaml` first.',
|
|
1676
|
+
},
|
|
1677
|
+
},
|
|
1678
|
+
failure_modes: failureModes,
|
|
1679
|
+
},
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Summary mode — render the claim question / drift summary as user-facing
|
|
1684
|
+
// prose, and the dispatch as a structured object.
|
|
1685
|
+
if (alignmentState === 'drift_detected' || alignmentState === 'needs_breakdown') {
|
|
1686
|
+
const summary =
|
|
1687
|
+
alignmentState === 'needs_breakdown'
|
|
1688
|
+
? `**${alignmentState}** on \`${focusSpecId}\` — the focus epic has no children on disk. The breakdown brief loads on demand.`
|
|
1689
|
+
: `**drift_detected** on \`${focusSpecId}\`${typeof changedCount === 'number' ? ` — ${changedCount} spec${changedCount === 1 ? '' : 's'} changed since the last alignment review` : ''}. Full review brief loads on demand.`;
|
|
1690
|
+
return {
|
|
1691
|
+
user_prompt: [
|
|
1692
|
+
'',
|
|
1693
|
+
'---',
|
|
1694
|
+
'',
|
|
1695
|
+
`⚠️ ${summary}`,
|
|
1696
|
+
'',
|
|
1697
|
+
'Reply `review` to load the full alignment-review brief and decide, or `defer` / `4` to skip.',
|
|
1698
|
+
].join('\n'),
|
|
1699
|
+
agent_dispatch: {
|
|
1700
|
+
phase: alignmentState === 'needs_breakdown' ? 'needs_breakdown_review' : 'drift_review',
|
|
1701
|
+
on_user_reply: {
|
|
1702
|
+
review: {
|
|
1703
|
+
tokens: ['review', 'show review'],
|
|
1704
|
+
actions: [
|
|
1705
|
+
{
|
|
1706
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1707
|
+
args: packNextOnDeckToolArgs(args, { summary: false }),
|
|
1708
|
+
},
|
|
1709
|
+
],
|
|
1710
|
+
comment:
|
|
1711
|
+
'Re-call without summary; output the returned user_prompt_markdown verbatim and follow the drift_resolution_menu in the new agent_dispatch.',
|
|
1712
|
+
},
|
|
1713
|
+
defer: {
|
|
1714
|
+
tokens: ['defer', 'skip', '4'],
|
|
1715
|
+
reply: 'Deferred. Re-run `/hs-next` when ready, or re-pin `.heyspex/focus.yaml` first.',
|
|
1716
|
+
},
|
|
1717
|
+
},
|
|
1718
|
+
failure_modes: failureModes,
|
|
1719
|
+
},
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Fresh alignment — straight claim question, unless rollup blocked the work.
|
|
1724
|
+
// Resolve the session label MCP-side via the same env auto-detect the claim/
|
|
1725
|
+
// file-spec handlers use (the next_on_deck schema accepts no agent/session_id,
|
|
1726
|
+
// so detectAgentIdentity falls back to CLAUDE_CODE_SESSION_ID / 'local'). This
|
|
1727
|
+
// replaces the old literal `{short-session}` placeholder + footnote that leaked
|
|
1728
|
+
// unsubstituted into the user's terminal.
|
|
1729
|
+
const launchIdentity = detectAgentIdentity(args.agent, args.session_id);
|
|
1730
|
+
const shortSession = (launchIdentity.sessionId || 'local').slice(0, 8) || 'local';
|
|
1731
|
+
// Real harness name ("Cursor" / "Claude Code" / "External agent") so the claim
|
|
1732
|
+
// brief names the tool that actually launched, not a hard-coded one. Sourced
|
|
1733
|
+
// from the harness's own session env vars, or HEYSPEX_AGENT set by the spex
|
|
1734
|
+
// console spawner ([[HS-EPIC-NWUE]] / [[HS-EPIC-JLTL]]).
|
|
1735
|
+
const launchTool = launchIdentity.agent || 'your agent';
|
|
1736
|
+
|
|
1737
|
+
if (claimTarget.rollup_state === 'reviewer_blocked') {
|
|
1738
|
+
const n = claimTarget.rollup_detail?.in_review ?? 'Some';
|
|
1739
|
+
return {
|
|
1740
|
+
user_prompt: [
|
|
1741
|
+
'',
|
|
1742
|
+
'---',
|
|
1743
|
+
'',
|
|
1744
|
+
`⏳ **Reviewer-blocked** — ${n} child task(s) under \`${specId}\` are in review. Breakdown is already done — do not claim this epic to break it into tasks.`,
|
|
1745
|
+
'',
|
|
1746
|
+
"Walk each in-review child's HS-REV companion (or run `/hs-next review <task-id>`). Re-run `/hs-next` after children close to surface epic close-out.",
|
|
1747
|
+
].join('\n'),
|
|
1748
|
+
agent_dispatch: {
|
|
1749
|
+
phase: 'rollup_reviewer_blocked',
|
|
1750
|
+
next_action_hint: 'wait_for_reviewer',
|
|
1751
|
+
failure_modes: failureModes,
|
|
1752
|
+
},
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (claimTarget.rollup_state === 'terminal_leaf') {
|
|
1757
|
+
if (claimTarget.next_action_hint === 'wait_for_reviewer') {
|
|
1758
|
+
return {
|
|
1759
|
+
user_prompt: [
|
|
1760
|
+
'',
|
|
1761
|
+
'---',
|
|
1762
|
+
'',
|
|
1763
|
+
`⏳ **Awaiting review** — \`${specId}\` is \`in_review\`. Implementation is done; walk its HS-REV companion and let the human reviewer commit to close.`,
|
|
1764
|
+
'',
|
|
1765
|
+
'Do not claim this task to implement it. Re-run `/hs-next` without a spec id for the next open task.',
|
|
1766
|
+
].join('\n'),
|
|
1767
|
+
agent_dispatch: {
|
|
1768
|
+
phase: 'terminal_leaf_awaiting_review',
|
|
1769
|
+
next_action_hint: 'wait_for_reviewer',
|
|
1770
|
+
failure_modes: failureModes,
|
|
1771
|
+
},
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
const st = claimTarget.terminal_status || 'closed';
|
|
1775
|
+
return {
|
|
1776
|
+
user_prompt: [
|
|
1777
|
+
'',
|
|
1778
|
+
'---',
|
|
1779
|
+
'',
|
|
1780
|
+
`✅ **Already terminal** — \`${specId}\` has status \`${st}\`. There is nothing to implement or claim.`,
|
|
1781
|
+
'',
|
|
1782
|
+
'Re-run `/hs-next` without a spec id for the next open task, or pick a different spec.',
|
|
1783
|
+
].join('\n'),
|
|
1784
|
+
agent_dispatch: {
|
|
1785
|
+
phase: 'terminal_leaf_not_claimable',
|
|
1786
|
+
next_action_hint: 'not_claimable',
|
|
1787
|
+
failure_modes: failureModes,
|
|
1788
|
+
},
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (claimTarget.rollup_state === 'ready_for_close') {
|
|
1793
|
+
if (shouldAutoClaimOnDeck(args)) {
|
|
1794
|
+
return buildAutoClaimOrchestration({
|
|
1795
|
+
specId,
|
|
1796
|
+
level,
|
|
1797
|
+
launchTool,
|
|
1798
|
+
shortSession,
|
|
1799
|
+
args,
|
|
1800
|
+
failureModes,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
return {
|
|
1804
|
+
user_prompt: [
|
|
1805
|
+
'',
|
|
1806
|
+
'---',
|
|
1807
|
+
'',
|
|
1808
|
+
`📋 **Ready for close-out** — every child of \`${specId}\` is closed. Next: \`heyspex_submit_for_review({ spec_id: "<review-unit>" })\` per session-close-protocol (never hand-edit frontmatter status), then append change-log + HS-REV when the active grain requires it.`,
|
|
1809
|
+
'',
|
|
1810
|
+
`Type \`proceed\` (or \`yes\` / \`confirm\`) if you want to claim **${level}** \`${specId}\` and perform close-out now.`,
|
|
1811
|
+
].join('\n'),
|
|
1812
|
+
agent_dispatch: {
|
|
1813
|
+
phase: 'rollup_ready_for_close',
|
|
1814
|
+
next_action_hint: 'flip_to_in_review',
|
|
1815
|
+
on_user_reply: {
|
|
1816
|
+
confirm: {
|
|
1817
|
+
tokens: ['yes', 'proceed', 'confirm'],
|
|
1818
|
+
actions: [
|
|
1819
|
+
{
|
|
1820
|
+
tool: 'mcp__heyspex__heyspex_claim_spec',
|
|
1821
|
+
args: { spec_id: specId },
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1825
|
+
args: packNextOnDeckToolArgs(args, { summary: false }),
|
|
1826
|
+
},
|
|
1827
|
+
],
|
|
1828
|
+
execute_after: true,
|
|
1829
|
+
},
|
|
1830
|
+
default: 'respond_to_user_question',
|
|
1831
|
+
},
|
|
1832
|
+
failure_modes: failureModes,
|
|
1833
|
+
},
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Missing-summary fallback: when the leaf has no authored summary, don't jump to
|
|
1838
|
+
// the claim. Hand the agent the shared authoring contract so it writes a real
|
|
1839
|
+
// plain-language summary to the spec file first, then re-shows the brief.
|
|
1840
|
+
// A run front asked at milestone/epic grain arms the milestone's dispatcher
|
|
1841
|
+
// run — it never claims the task the walk landed on ([[HS-3C9M-A4TX-CAP]]).
|
|
1842
|
+
const armGrain = resolveArmGrainTarget(result, resolveOnDeckFront(args));
|
|
1843
|
+
if (armGrain) {
|
|
1844
|
+
return buildArmRunOrchestration({
|
|
1845
|
+
arm: armGrain,
|
|
1846
|
+
front: resolveOnDeckFront(args),
|
|
1847
|
+
failureModes,
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
if (!claimTarget.summary || !String(claimTarget.summary).trim()) {
|
|
1852
|
+
const relPath = claimTarget.relPath || null;
|
|
1853
|
+
// [[HS-T41O-NZ8S-DIAG]]: when the console row is the source of record the
|
|
1854
|
+
// brief names the console write, never a file edit. Local reliance with
|
|
1855
|
+
// the file on disk keeps the file-edit brief below byte-for-byte.
|
|
1856
|
+
if (summaryWriteTargetsConsole(result, claimTarget, args)) {
|
|
1857
|
+
const consoleUrl = (() => {
|
|
1858
|
+
try {
|
|
1859
|
+
return effectiveConsoleUrl();
|
|
1860
|
+
} catch {
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1863
|
+
})();
|
|
1864
|
+
const authoringPrompt = buildSummaryConsoleWritePrompt({
|
|
1865
|
+
specId,
|
|
1866
|
+
name: claimTarget.title || specId,
|
|
1867
|
+
type: claimTarget.level || 'task',
|
|
1868
|
+
parent: claimTarget.parent || null,
|
|
1869
|
+
consoleUrl,
|
|
1870
|
+
});
|
|
1871
|
+
return {
|
|
1872
|
+
user_prompt: [
|
|
1873
|
+
'',
|
|
1874
|
+
'---',
|
|
1875
|
+
'',
|
|
1876
|
+
`📝 \`${displaySpecId}\` has no \`summary:\` yet. Author it first (this session is cloud-reliant — the console row is the source of record, so write it with \`heyspex_author_summary\`, not a file edit), then I'll show the summary and ask to claim.`,
|
|
1877
|
+
'',
|
|
1878
|
+
authoringPrompt,
|
|
1879
|
+
].join('\n'),
|
|
1880
|
+
agent_dispatch: {
|
|
1881
|
+
phase: 'author_summary_then_claim',
|
|
1882
|
+
execute_instruction:
|
|
1883
|
+
`Output user_prompt_markdown verbatim, author the summary, then write it through the console with heyspex_author_summary { spec_id: "${specId}", summary: "<your summary>" } (load it first via heyspex_load_tools { names: ["heyspex_author_summary"] }; it PUTs /api/specs/:id — the console row is the source of record in cloud reliance, so do NOT Edit a vault file for this), then re-run heyspex_next_on_deck { summary: true } to re-show the brief with the summary and ask to claim. Do not claim before the summary is written.`,
|
|
1884
|
+
on_complete: [
|
|
1885
|
+
{
|
|
1886
|
+
tool: 'mcp__heyspex__heyspex_load_tools',
|
|
1887
|
+
args: { names: ['heyspex_author_summary'] },
|
|
1888
|
+
comment: 'heyspex_author_summary is a deferred tool — make it visible before calling it.',
|
|
1889
|
+
},
|
|
1890
|
+
{
|
|
1891
|
+
tool: 'mcp__heyspex__heyspex_author_summary',
|
|
1892
|
+
args: { spec_id: specId, summary: '<the summary you authored — prose only, no YAML>' },
|
|
1893
|
+
comment: 'Console write: PUT /api/specs/:id with only `summary` changed. Nothing is written to disk.',
|
|
1894
|
+
},
|
|
1895
|
+
{
|
|
1896
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1897
|
+
args: packNextOnDeckToolArgs(args, { summary: true }),
|
|
1898
|
+
comment: 'Re-show the claim brief once the console holds the summary.',
|
|
1899
|
+
},
|
|
1900
|
+
],
|
|
1901
|
+
failure_modes: failureModes,
|
|
1902
|
+
},
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
const authoringPrompt = relPath
|
|
1906
|
+
? buildSummaryFillInPrompt({
|
|
1907
|
+
specId,
|
|
1908
|
+
name: claimTarget.title || specId,
|
|
1909
|
+
type: claimTarget.level || 'task',
|
|
1910
|
+
relPath,
|
|
1911
|
+
parent: claimTarget.parent || null,
|
|
1912
|
+
})
|
|
1913
|
+
: null;
|
|
1914
|
+
const notice = authoringPrompt
|
|
1915
|
+
? [
|
|
1916
|
+
'',
|
|
1917
|
+
'---',
|
|
1918
|
+
'',
|
|
1919
|
+
`📝 \`${displaySpecId}\` has no \`summary:\` yet. Author it first, then I'll show the summary and ask to claim.`,
|
|
1920
|
+
'',
|
|
1921
|
+
authoringPrompt,
|
|
1922
|
+
].join('\n')
|
|
1923
|
+
: [
|
|
1924
|
+
'',
|
|
1925
|
+
'---',
|
|
1926
|
+
'',
|
|
1927
|
+
`📝 \`${displaySpecId}\` has no \`summary:\` yet — author one with \`heyspex_author_summary\` (cloud reliance) or in the file's frontmatter (local reliance) before claiming.`,
|
|
1928
|
+
].join('\n');
|
|
1929
|
+
return {
|
|
1930
|
+
user_prompt: notice,
|
|
1931
|
+
agent_dispatch: {
|
|
1932
|
+
phase: 'author_summary_then_claim',
|
|
1933
|
+
execute_instruction:
|
|
1934
|
+
'Output user_prompt_markdown verbatim, author the summary into the spec frontmatter via Edit (the vault file on disk is the source of record), then re-run heyspex_next_on_deck { summary: true } to re-show the brief with the summary and ask to claim. Do not claim before the summary is written.',
|
|
1935
|
+
on_complete: [
|
|
1936
|
+
{
|
|
1937
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1938
|
+
args: packNextOnDeckToolArgs(args, { summary: true }),
|
|
1939
|
+
comment: 'Re-show the claim brief once the summary is written to the spec file.',
|
|
1940
|
+
},
|
|
1941
|
+
],
|
|
1942
|
+
failure_modes: failureModes,
|
|
1943
|
+
},
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (shouldAutoClaimOnDeck(args)) {
|
|
1948
|
+
return buildAutoClaimOrchestration({
|
|
1949
|
+
specId,
|
|
1950
|
+
level,
|
|
1951
|
+
launchTool,
|
|
1952
|
+
shortSession,
|
|
1953
|
+
args,
|
|
1954
|
+
failureModes,
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
return {
|
|
1959
|
+
user_prompt: [
|
|
1960
|
+
'',
|
|
1961
|
+
'---',
|
|
1962
|
+
'',
|
|
1963
|
+
`Ready to claim **${level}** \`${displaySpecId}\` to **${intent}**, as **${launchTool} · \`${shortSession}\`**. Type \`proceed\` (or \`yes\` / \`confirm\`) to claim it and begin — or name another spec to redirect.`,
|
|
1964
|
+
].join('\n'),
|
|
1965
|
+
agent_dispatch: {
|
|
1966
|
+
phase: 'claim_authorization',
|
|
1967
|
+
on_user_reply: {
|
|
1968
|
+
confirm: {
|
|
1969
|
+
tokens: ['yes', 'proceed', 'confirm'],
|
|
1970
|
+
actions: [
|
|
1971
|
+
{
|
|
1972
|
+
tool: 'mcp__heyspex__heyspex_claim_spec',
|
|
1973
|
+
args: { spec_id: specId },
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
tool: 'mcp__heyspex__heyspex_next_on_deck',
|
|
1977
|
+
args: packNextOnDeckToolArgs(args, { summary: false }),
|
|
1978
|
+
},
|
|
1979
|
+
],
|
|
1980
|
+
execute_after: true,
|
|
1981
|
+
comment:
|
|
1982
|
+
'Tools are pre-allowlisted in .claude/settings.local.json — no second permission ask.',
|
|
1983
|
+
},
|
|
1984
|
+
default: 'respond_to_user_question',
|
|
1985
|
+
},
|
|
1986
|
+
failure_modes: failureModes,
|
|
1987
|
+
},
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Apply the composer output to the response shape:
|
|
1993
|
+
* • appends `user_prompt` (if any) to `user_prompt_markdown`
|
|
1994
|
+
* • sets the structured `agent_dispatch` field
|
|
1995
|
+
* • mirrors `user_prompt_markdown` into `prompt_markdown` (the @deprecated
|
|
1996
|
+
* alias) — `prompt_markdown` now never carries agent-routing prose, so
|
|
1997
|
+
* legacy consumers that print it can no longer leak dispatch tables.
|
|
1998
|
+
*
|
|
1999
|
+
* Phase-aware via `args.summary`. See [[HS-TASK-HSNX]],
|
|
2000
|
+
* [[HS-RES-J6CD-migrate-slash-command-orchestration-from]].
|
|
2001
|
+
*/
|
|
2002
|
+
export function enrichOnDeckWithAgentOrchestration(result, args = {}) {
|
|
2003
|
+
if (!result || result.error) return result;
|
|
2004
|
+
const composed = composeAgentOrchestration(result, args);
|
|
2005
|
+
if (!composed) return result;
|
|
2006
|
+
const { user_prompt, agent_dispatch } = composed;
|
|
2007
|
+
const updated = { ...result };
|
|
2008
|
+
if (user_prompt) {
|
|
2009
|
+
const existing = updated.user_prompt_markdown ?? updated.prompt_markdown ?? '';
|
|
2010
|
+
updated.user_prompt_markdown = `${existing}${user_prompt}`;
|
|
2011
|
+
updated.prompt_markdown = updated.user_prompt_markdown;
|
|
2012
|
+
}
|
|
2013
|
+
if (agent_dispatch) {
|
|
2014
|
+
updated.agent_dispatch = agent_dispatch;
|
|
2015
|
+
}
|
|
2016
|
+
return updated;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/** Wrap a gate prompt into the structured /hs-next response shape. */
|
|
2020
|
+
export function buildAlignmentGateResponse({
|
|
2021
|
+
gate,
|
|
2022
|
+
grain,
|
|
2023
|
+
id,
|
|
2024
|
+
name,
|
|
2025
|
+
status,
|
|
2026
|
+
roadmapId,
|
|
2027
|
+
focusSpecPath,
|
|
2028
|
+
focusReason,
|
|
2029
|
+
promptMarkdown,
|
|
2030
|
+
suggestedActions = [],
|
|
2031
|
+
checkpointReason,
|
|
2032
|
+
drift,
|
|
2033
|
+
}) {
|
|
2034
|
+
const response = {
|
|
2035
|
+
status: gate, // 'needs_breakdown' | 'alignment_review'
|
|
2036
|
+
kind: grain,
|
|
2037
|
+
spec_id: id,
|
|
2038
|
+
spec_title: name,
|
|
2039
|
+
title: name,
|
|
2040
|
+
source_kind: `${gate}_gate`,
|
|
2041
|
+
roadmap_id: roadmapId,
|
|
2042
|
+
spec_path: focusSpecPath,
|
|
2043
|
+
prompt_markdown: promptMarkdown,
|
|
2044
|
+
next_eligible_spec: null,
|
|
2045
|
+
next_action_hint: mapStateToHint(
|
|
2046
|
+
gate === 'needs_breakdown'
|
|
2047
|
+
? grain === 'milestone'
|
|
2048
|
+
? 'milestone_needs_breakdown'
|
|
2049
|
+
: 'epic_empty'
|
|
2050
|
+
: 'epic_empty',
|
|
2051
|
+
),
|
|
2052
|
+
suggested_actions: suggestedActions,
|
|
2053
|
+
};
|
|
2054
|
+
if (typeof status === 'string') response.spec_status = status;
|
|
2055
|
+
if (focusReason) response.focus_reason = focusReason;
|
|
2056
|
+
if (checkpointReason) response.alignment_reason = checkpointReason;
|
|
2057
|
+
if (drift) {
|
|
2058
|
+
response.alignment_analysis = {
|
|
2059
|
+
has_drift: drift.hasDrift,
|
|
2060
|
+
finding_count: drift.findings.length,
|
|
2061
|
+
findings: drift.findings,
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
return response;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* Summary-mode response diet ([[HS-TASK-CBPK]] / [[mcp-tool-response-size-budget]]).
|
|
2069
|
+
* Keeps the agent-routing surface under ~600 structured tokens so /hs-next does
|
|
2070
|
+
* not burn context before work starts. Full mode is unchanged.
|
|
2071
|
+
*/
|
|
2072
|
+
export function slimOnDeckSummaryResponse(result) {
|
|
2073
|
+
if (!result || result.error) return result;
|
|
2074
|
+
|
|
2075
|
+
const slimFailureModes = (failureModes) => {
|
|
2076
|
+
if (!failureModes || typeof failureModes !== 'object') return undefined;
|
|
2077
|
+
const out = {};
|
|
2078
|
+
for (const [code, entry] of Object.entries(failureModes)) {
|
|
2079
|
+
out[code] = typeof entry === 'string' ? entry : entry?.handling;
|
|
2080
|
+
}
|
|
2081
|
+
return out;
|
|
2082
|
+
};
|
|
2083
|
+
|
|
2084
|
+
const slimDispatch = (dispatch) => {
|
|
2085
|
+
if (!dispatch || typeof dispatch !== 'object') return undefined;
|
|
2086
|
+
const out = { phase: dispatch.phase };
|
|
2087
|
+
if (dispatch.next_action_hint) out.next_action_hint = dispatch.next_action_hint;
|
|
2088
|
+
if (dispatch.on_user_reply) out.on_user_reply = dispatch.on_user_reply;
|
|
2089
|
+
if (dispatch.execute_instruction) out.execute_instruction = dispatch.execute_instruction;
|
|
2090
|
+
if (dispatch.on_complete) out.on_complete = dispatch.on_complete;
|
|
2091
|
+
if (dispatch.on_handoff) out.on_handoff = dispatch.on_handoff;
|
|
2092
|
+
if (dispatch.arm_target) out.arm_target = dispatch.arm_target;
|
|
2093
|
+
if (dispatch.drift_resolution_menu) out.drift_resolution_menu = dispatch.drift_resolution_menu;
|
|
2094
|
+
const fm = slimFailureModes(dispatch.failure_modes);
|
|
2095
|
+
if (fm) out.failure_modes = fm;
|
|
2096
|
+
return out;
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
const slimClaimTarget = (ct) => {
|
|
2100
|
+
if (!ct || typeof ct !== 'object') return undefined;
|
|
2101
|
+
const out = {
|
|
2102
|
+
level: ct.level,
|
|
2103
|
+
spec_id: ct.spec_id,
|
|
2104
|
+
title: ct.title,
|
|
2105
|
+
intent_verb: ct.intent_verb,
|
|
2106
|
+
tone_token: ct.tone_token,
|
|
2107
|
+
alignment_state: ct.alignment_state,
|
|
2108
|
+
summary: ct.summary,
|
|
2109
|
+
hierarchy_breadcrumb: ct.hierarchy_breadcrumb,
|
|
2110
|
+
relPath: ct.relPath,
|
|
2111
|
+
};
|
|
2112
|
+
if (ct.rollup_state) out.rollup_state = ct.rollup_state;
|
|
2113
|
+
if (ct.next_action_hint) out.next_action_hint = ct.next_action_hint;
|
|
2114
|
+
if (ct.rollup_detail) out.rollup_detail = ct.rollup_detail;
|
|
2115
|
+
if (ct.parent && typeof ct.parent === 'object') {
|
|
2116
|
+
out.parent = {
|
|
2117
|
+
id: ct.parent.id,
|
|
2118
|
+
kind: ct.parent.kind,
|
|
2119
|
+
title: ct.parent.title,
|
|
2120
|
+
relPath: ct.parent.relPath,
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
return out;
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
const slim = {
|
|
2127
|
+
spec_id: result.spec_id,
|
|
2128
|
+
title: result.title,
|
|
2129
|
+
status: result.status,
|
|
2130
|
+
source_kind: result.source_kind,
|
|
2131
|
+
roadmap_id: result.roadmap_id,
|
|
2132
|
+
spec_path: result.spec_path,
|
|
2133
|
+
user_prompt_markdown: result.user_prompt_markdown,
|
|
2134
|
+
run_front: result.run_front || 'handheld',
|
|
2135
|
+
};
|
|
2136
|
+
if (result.alignment_summary) slim.alignment_summary = result.alignment_summary;
|
|
2137
|
+
const claimTarget = slimClaimTarget(result.claim_target);
|
|
2138
|
+
if (claimTarget) slim.claim_target = claimTarget;
|
|
2139
|
+
if (result.console_status) slim.console_status = result.console_status;
|
|
2140
|
+
const dispatch = slimDispatch(result.agent_dispatch);
|
|
2141
|
+
if (dispatch) slim.agent_dispatch = dispatch;
|
|
2142
|
+
|
|
2143
|
+
for (const key of [
|
|
2144
|
+
'pinned_epic',
|
|
2145
|
+
'pinned_milestone',
|
|
2146
|
+
'pinned_task',
|
|
2147
|
+
'seed_epic',
|
|
2148
|
+
'candidate_epics',
|
|
2149
|
+
'child_state',
|
|
2150
|
+
'next_action_hint',
|
|
2151
|
+
'unmet_blockers',
|
|
2152
|
+
'blocker_severity',
|
|
2153
|
+
'gating_blockers',
|
|
2154
|
+
'stale_focus_pin',
|
|
2155
|
+
'review_candidates',
|
|
2156
|
+
'milestone_next_epics',
|
|
2157
|
+
// [[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
|
|
2158
|
+
// 7-9 / HS-ARWE: the sprint attached by `attachSprintToOnDeck` must
|
|
2159
|
+
// survive summary-mode slimming the same as `gating_blockers` does.
|
|
2160
|
+
'sprint',
|
|
2161
|
+
]) {
|
|
2162
|
+
if (result[key] !== undefined) slim[key] = result[key];
|
|
2163
|
+
}
|
|
2164
|
+
return slim;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
export function finalizeOnDeckPayload(result, isSummary, runEnricherChain) {
|
|
2168
|
+
const enriched = runEnricherChain(result);
|
|
2169
|
+
return isSummary ? slimOnDeckSummaryResponse(enriched) : enriched;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
export function resolveNextOnDeckPayload(vaultRoot, config, args = {}) {
|
|
2173
|
+
args = { ...args, front: resolveOnDeckFront(args) };
|
|
2174
|
+
const isSummary = args.summary === true || args.summary === 'true';
|
|
2175
|
+
|
|
2176
|
+
// Summary mode skips plan hydration, conflict scan, WIP block, full
|
|
2177
|
+
// alignment-gate brief, and design context. Keeps the claim-target banner
|
|
2178
|
+
// (cheap, needed to render the question) and replaces the heavy alignment
|
|
2179
|
+
// enricher with a lightweight state probe — see [[hs-next-summary-first-flow]].
|
|
2180
|
+
const runEnricherChain = (result) => {
|
|
2181
|
+
if (isSummary) {
|
|
2182
|
+
const stripped = { ...result, prompt_markdown: '' };
|
|
2183
|
+
const probe = probeAlignmentState(vaultRoot, args);
|
|
2184
|
+
const tagged = probe
|
|
2185
|
+
? {
|
|
2186
|
+
...stripped,
|
|
2187
|
+
alignment_summary: probe,
|
|
2188
|
+
// Signal drift_detected to deriveAlignmentState so the claim-target
|
|
2189
|
+
// banner reflects the gate state. The full drift body stays unbuilt;
|
|
2190
|
+
// the slash command re-calls without summary to load it on demand.
|
|
2191
|
+
...(probe.state === 'drift_detected' || probe.state === 'needs_breakdown'
|
|
2192
|
+
? { alignment_drift: { stale_corpus: [], last_reviewed_at: null } }
|
|
2193
|
+
: {}),
|
|
2194
|
+
}
|
|
2195
|
+
: stripped;
|
|
2196
|
+
return enrichOnDeckWithRunFront(
|
|
2197
|
+
enrichOnDeckWithAgentOrchestration(
|
|
2198
|
+
enrichOnDeckWithClaimTarget(vaultRoot, tagged, args),
|
|
2199
|
+
args,
|
|
2200
|
+
),
|
|
2201
|
+
args,
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
return enrichOnDeckWithRunFront(
|
|
2205
|
+
enrichOnDeckWithAgentOrchestration(
|
|
2206
|
+
enrichOnDeckWithClaimTarget(
|
|
2207
|
+
vaultRoot,
|
|
2208
|
+
enrichOnDeckWithRecalledMemories(
|
|
2209
|
+
vaultRoot,
|
|
2210
|
+
enrichOnDeckWithWip(
|
|
2211
|
+
vaultRoot,
|
|
2212
|
+
enrichOnDeckWithAlignment(
|
|
2213
|
+
vaultRoot,
|
|
2214
|
+
// Amendments wrap conflicts so the "read the amending doc first"
|
|
2215
|
+
// warning prepends ABOVE the conflict section (most-important-first).
|
|
2216
|
+
enrichOnDeckWithAmendments(
|
|
2217
|
+
vaultRoot,
|
|
2218
|
+
enrichOnDeckWithConflicts(
|
|
2219
|
+
vaultRoot,
|
|
2220
|
+
enrichOnDeckWithExpectedBreakage(
|
|
2221
|
+
vaultRoot,
|
|
2222
|
+
enrichOnDeckWithPlans(vaultRoot, result, args),
|
|
2223
|
+
),
|
|
2224
|
+
),
|
|
2225
|
+
),
|
|
2226
|
+
),
|
|
2227
|
+
args,
|
|
2228
|
+
),
|
|
2229
|
+
args,
|
|
2230
|
+
),
|
|
2231
|
+
args,
|
|
2232
|
+
),
|
|
2233
|
+
args,
|
|
2234
|
+
),
|
|
2235
|
+
args,
|
|
2236
|
+
);
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
// If spec_id is provided, fetch that specific spec directly.
|
|
2240
|
+
//
|
|
2241
|
+
// [[HS-DJWP-MCPC-TASK]] round 2, AC1: `args.__cloud_direct_spec` — pre-fetched
|
|
2242
|
+
// over HTTP by `handleNextOnDeck` via `loadSpecByIdViaTier` when this session
|
|
2243
|
+
// is cloud-reliant — replaces the `findSpecFile` + `loadSpec` disk pair below
|
|
2244
|
+
// with the SAME shape, so this function stays synchronous (14+ call sites in
|
|
2245
|
+
// cli.test.mjs call it directly and destructure `{ result, error }` with no
|
|
2246
|
+
// `await` — converting it to async would break all of them). `undefined`
|
|
2247
|
+
// (every disk-reliant call site, and every caller that predates this task)
|
|
2248
|
+
// falls through to the original disk pair, unchanged.
|
|
2249
|
+
if (args.spec_id) {
|
|
2250
|
+
let specPath;
|
|
2251
|
+
let spec;
|
|
2252
|
+
if (args.__cloud_direct_spec !== undefined) {
|
|
2253
|
+
if (args.__cloud_direct_spec === null) {
|
|
2254
|
+
return { error: { error: 'SPEC_NOT_FOUND', message: `Spec ${args.spec_id} not found in vault` } };
|
|
2255
|
+
}
|
|
2256
|
+
specPath = args.__cloud_direct_spec.specPath;
|
|
2257
|
+
spec = { frontmatter: args.__cloud_direct_spec.frontmatter, body: args.__cloud_direct_spec.body };
|
|
2258
|
+
} else {
|
|
2259
|
+
specPath = findSpecFile(vaultRoot, args.spec_id);
|
|
2260
|
+
if (!specPath) {
|
|
2261
|
+
return { error: { error: 'SPEC_NOT_FOUND', message: `Spec ${args.spec_id} not found in vault` } };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
spec = loadSpec(vaultRoot, specPath);
|
|
2265
|
+
if (!spec) {
|
|
2266
|
+
return { error: { error: 'SPEC_LOAD_FAILED', message: `Could not load spec at ${specPath}` } };
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
let promptMarkdown = '';
|
|
2271
|
+
let designContext = null;
|
|
2272
|
+
|
|
2273
|
+
if (!isSummary) {
|
|
2274
|
+
// [[HS-TASK-G0HF]] the derived brief is the only path — a missing
|
|
2275
|
+
// `## NEXT STEP` no longer hard-errors (NO_NEXT_STEP retired); the block,
|
|
2276
|
+
// if present, is the Tier-0 override prepended by buildExecutionBrief.
|
|
2277
|
+
promptMarkdown = deriveTaskBrief(vaultRoot, {
|
|
2278
|
+
id: spec.frontmatter.id || args.spec_id,
|
|
2279
|
+
name: spec.frontmatter.name || args.spec_id,
|
|
2280
|
+
status: spec.frontmatter.status || 'unknown',
|
|
2281
|
+
roadmapId: args.roadmap_id || config.defaultRoadmapId,
|
|
2282
|
+
frontmatter: spec.frontmatter,
|
|
2283
|
+
body: spec.body,
|
|
2284
|
+
specPath,
|
|
2285
|
+
// [[HS-H227-MCPC-TASK]]: pre-fetched over HTTP by `handleNextOnDeck`
|
|
2286
|
+
// (`prefetchCloudTaskGraph`) when cloud-reliant; undefined everywhere
|
|
2287
|
+
// else, which keeps `deriveTaskBrief`'s `loadRef` on disk.
|
|
2288
|
+
cloudTaskGraph: args.__cloud_task_graph,
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
// Detect UI tags and load design context if applicable
|
|
2292
|
+
const uiTags = detectUITags(spec.frontmatter);
|
|
2293
|
+
if (uiTags.length > 0) {
|
|
2294
|
+
// [[HS-DJWP-MCPC-TASK]] round 2: `__cloud_design_decisions` — pre-fetched
|
|
2295
|
+
// over HTTP by `handleNextOnDeck` (only once it already knows uiTags is
|
|
2296
|
+
// non-empty, from the SAME pre-fetched spec) — falls through to the
|
|
2297
|
+
// disk scan when absent, same additive pattern as `__cloud_direct_spec`.
|
|
2298
|
+
const designDecisions =
|
|
2299
|
+
args.__cloud_design_decisions !== undefined
|
|
2300
|
+
? args.__cloud_design_decisions
|
|
2301
|
+
: loadDesignMemories(vaultRoot, uiTags);
|
|
2302
|
+
if (designDecisions.length > 0) {
|
|
2303
|
+
designContext = {
|
|
2304
|
+
triggered_by_tags: uiTags,
|
|
2305
|
+
decisions: designDecisions,
|
|
2306
|
+
};
|
|
2307
|
+
const designSection = buildDesignContextSection(designDecisions, uiTags);
|
|
2308
|
+
promptMarkdown = designSection + promptMarkdown;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
const result = {
|
|
2314
|
+
spec_id: spec.frontmatter.id || args.spec_id,
|
|
2315
|
+
title: spec.frontmatter.name || args.spec_id,
|
|
2316
|
+
status: spec.frontmatter.status || 'unknown',
|
|
2317
|
+
source_kind: 'direct_lookup',
|
|
2318
|
+
roadmap_id: args.roadmap_id || config.defaultRoadmapId,
|
|
2319
|
+
prompt_markdown: promptMarkdown,
|
|
2320
|
+
spec_path: specPath,
|
|
2321
|
+
vault_root: vaultRoot,
|
|
2322
|
+
resolved_at: new Date().toISOString(),
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
if (designContext) {
|
|
2326
|
+
result.design_context = designContext;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
const directExplanation = buildOnDeckExplanation(result.spec_id, [
|
|
2330
|
+
{ id: result.spec_id, frontmatter: spec.frontmatter || {} },
|
|
2331
|
+
], vaultRoot);
|
|
2332
|
+
attachWhyNowFields(result, directExplanation, args.neon_pin_spec_id ?? null);
|
|
2333
|
+
|
|
2334
|
+
return { result: finalizeOnDeckPayload(result, isSummary, runEnricherChain) };
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const roadmapId = args.roadmap_id || config.defaultRoadmapId;
|
|
2338
|
+
const result = resolveNextOnDeck(vaultRoot, roadmapId, {
|
|
2339
|
+
ignoreFocus: args.ignore_focus === true || args.ignore_focus === 'true',
|
|
2340
|
+
includeHorizon: args.include_horizon === true || args.include_horizon === 'true',
|
|
2341
|
+
// The Neon focus ids, injected by `injectNeonFocusPin` at the async handler
|
|
2342
|
+
// boundary (the file is dormant on a Neon-up machine): layer-1 pin +
|
|
2343
|
+
// layer-2 selection spec_path.
|
|
2344
|
+
neonPinSpecId: args.neon_pin_spec_id,
|
|
2345
|
+
neonSelectionSpecPath: args.neon_selection_spec_path,
|
|
2346
|
+
// [[HS-TASK-JHIA]] frozen milestone snapshot epic order, fetched over HTTP by
|
|
2347
|
+
// `injectFrozenMilestoneOrder` at the async handler boundary (the cli has no
|
|
2348
|
+
// Neon client). Undefined → live scheduler order.
|
|
2349
|
+
frozenEpicOrder: args.frozen_epic_order,
|
|
2350
|
+
// [[HS-DJWP-MCPC-TASK]] — pre-fetched over HTTP by `handleNextOnDeck` when
|
|
2351
|
+
// cloud-reliant; undefined (falls through to disk) everywhere else,
|
|
2352
|
+
// including every OTHER caller of `resolveNextOnDeckPayload`.
|
|
2353
|
+
schedulerRows: args.__cloud_scheduler_rows,
|
|
2354
|
+
pillarTierById: args.__cloud_pillar_tier_by_id,
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
if (result.error) {
|
|
2358
|
+
return { error: result };
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
result.vault_root = vaultRoot;
|
|
2362
|
+
result.resolved_at = new Date().toISOString();
|
|
2363
|
+
return { result: finalizeOnDeckPayload(result, isSummary, runEnricherChain) };
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/** Append a suffix to BOTH the canonical + deprecated prompt fields (kept in sync). */
|
|
2367
|
+
export function appendToOnDeckPrompts(result, suffix) {
|
|
2368
|
+
if (typeof result.user_prompt_markdown === 'string') result.user_prompt_markdown += suffix;
|
|
2369
|
+
if (typeof result.prompt_markdown === 'string') result.prompt_markdown += suffix;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Render the passive overlap section for the on-deck brief ([[HS-TASK-FD5O]]).
|
|
2374
|
+
* Returns '' when no warnings (additive-only).
|
|
2375
|
+
*
|
|
2376
|
+
* @param {Array<{ other_agent: string, other_spec_id?: string, overlapping_globs: string[] }>} stompWarnings
|
|
2377
|
+
*/
|
|
2378
|
+
export function renderStompWarningsSection(stompWarnings) {
|
|
2379
|
+
if (!Array.isArray(stompWarnings) || stompWarnings.length === 0) return '';
|
|
2380
|
+
const lines = stompWarnings.map((w) => {
|
|
2381
|
+
const agent = w.other_agent || 'Another agent';
|
|
2382
|
+
const globs = Array.isArray(w.overlapping_globs) ? w.overlapping_globs : [];
|
|
2383
|
+
const globText = globs.map((g) => `\`${g}\``).join(', ') || '`(unknown path)`';
|
|
2384
|
+
return `- **${agent}** is also working in ${globText} — coordinate before editing overlapping files.`;
|
|
2385
|
+
});
|
|
2386
|
+
return `\n\n---\n\n## Parallel-agent overlap\n\n${lines.join('\n')}`;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/**
|
|
2390
|
+
* Inject stomp warnings into `user_prompt_markdown` when overlaps exist
|
|
2391
|
+
* ([[HS-TASK-FD5O]]). Sync / pure — the HTTP probe lives in
|
|
2392
|
+
* `augmentOnDeckWithStompWarnings`.
|
|
2393
|
+
*/
|
|
2394
|
+
export function enrichOnDeckWithStompWarnings(result, stompWarnings) {
|
|
2395
|
+
if (!result || result.error) return result;
|
|
2396
|
+
const section = renderStompWarningsSection(stompWarnings);
|
|
2397
|
+
if (!section) return result;
|
|
2398
|
+
appendToOnDeckPrompts(result, section);
|
|
2399
|
+
return { ...result, stomp_warnings: stompWarnings };
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Render the passive identity guidance section for the on-deck brief
|
|
2404
|
+
* ([[HS-TASK-E4OE]]). Returns '' when no nudges (additive-only).
|
|
2405
|
+
*
|
|
2406
|
+
* @param {Array<{ identity_name: string, trigger: string, message: string }>} identityNudges
|
|
2407
|
+
*/
|
|
2408
|
+
export function renderAgentGuidanceSection(identityNudges) {
|
|
2409
|
+
if (!Array.isArray(identityNudges) || identityNudges.length === 0) return '';
|
|
2410
|
+
const lines = identityNudges.map((n) => `- ${n.message}`);
|
|
2411
|
+
return `\n\n---\n\n## Agent guidance\n\n${lines.join('\n')}`;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* Inject identity nudges into `user_prompt_markdown` ([[HS-TASK-E4OE]]).
|
|
2416
|
+
* Sync / pure — the HTTP probe lives in `augmentOnDeckWithIdentityNudges`.
|
|
2417
|
+
*/
|
|
2418
|
+
export function enrichOnDeckWithIdentityNudges(result, identityNudges) {
|
|
2419
|
+
if (!result || result.error) return result;
|
|
2420
|
+
const section = renderAgentGuidanceSection(identityNudges);
|
|
2421
|
+
if (!section) return result;
|
|
2422
|
+
appendToOnDeckPrompts(result, section);
|
|
2423
|
+
return { ...result, identity_nudges: identityNudges };
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/**
|
|
2427
|
+
* Render the parallelism serialization hint section ([[HS-TASK-55VE]]).
|
|
2428
|
+
* Distinct from stomp overlap ([[HS-TASK-FD5O]]) — advises run-one-at-a-time
|
|
2429
|
+
* for shared depends_on or same-module affects_paths. Reused by scheduler
|
|
2430
|
+
* Phase 2 §3 ([[HS-MIL-SCHD-scheduler-and-priority-algorithm]]).
|
|
2431
|
+
*
|
|
2432
|
+
* @param {Array<{ kind: 'depends_on' | 'same_module', other_agent: string, other_spec_id: string, shared_dep?: string, overlapping_globs?: string[] }>} hints
|
|
2433
|
+
*/
|
|
2434
|
+
export function renderParallelismHintsSection(hints) {
|
|
2435
|
+
if (!Array.isArray(hints) || hints.length === 0) return '';
|
|
2436
|
+
const lines = hints.map((h) => {
|
|
2437
|
+
const agent = h.other_agent || 'Another agent';
|
|
2438
|
+
const spec = h.other_spec_id || 'another spec';
|
|
2439
|
+
if (h.kind === 'depends_on') {
|
|
2440
|
+
const dep = h.shared_dep ? `\`${h.shared_dep}\`` : 'a shared dependency';
|
|
2441
|
+
return `- **${agent}** holds \`${spec}\`, which shares ${dep} with this task — **run one at a time**, not in parallel.`;
|
|
2442
|
+
}
|
|
2443
|
+
const globs = Array.isArray(h.overlapping_globs)
|
|
2444
|
+
? h.overlapping_globs.map((g) => `\`${g}\``).join(', ')
|
|
2445
|
+
: '';
|
|
2446
|
+
const globText = globs || '`(unknown module)`';
|
|
2447
|
+
return `- **${agent}** holds \`${spec}\` in the same module (${globText}) — **serialize execution** rather than running both tasks in parallel.`;
|
|
2448
|
+
});
|
|
2449
|
+
return `\n\n---\n\n## Parallelism hint\n\n${lines.join('\n')}`;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Inject parallelism hints into `user_prompt_markdown` ([[HS-TASK-55VE]]).
|
|
2454
|
+
* Sync / pure — the HTTP probe lives in `augmentOnDeckWithParallelismHints`.
|
|
2455
|
+
*/
|
|
2456
|
+
export function enrichOnDeckWithParallelismHints(result, hints) {
|
|
2457
|
+
if (!result || result.error) return result;
|
|
2458
|
+
const section = renderParallelismHintsSection(hints);
|
|
2459
|
+
if (!section) return result;
|
|
2460
|
+
appendToOnDeckPrompts(result, section);
|
|
2461
|
+
return { ...result, parallelism_hints: hints };
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* Extend "one detection path" to the agent's on-deck brief ([[HS-EPIC-RSSO]] §5).
|
|
2466
|
+
* The milestone/epic drill builds the brief WITHOUT a gating-prerequisite scan,
|
|
2467
|
+
* so the agent saw no blocker while the console overlay showed one. Here we read
|
|
2468
|
+
* the SAME converged endpoint the overlay reads — `GET /api/specs/:id/blockers`
|
|
2469
|
+
* (now aggregator-backed) — for the drilled seed / claim-target spec, and
|
|
2470
|
+
* surface its gating subset. Agent and console therefore read the same bytes.
|
|
2471
|
+
*
|
|
2472
|
+
* Console-unreachable is a dev-only condition ([[console-unreachable-is-dev-only]]):
|
|
2473
|
+
* users never run a local console. On unreachable we emit an honest "start the
|
|
2474
|
+
* dev server" notice — never a second, drift-prone detector ([[no-fallbacks-by-default]]).
|
|
2475
|
+
* Skipped in summary mode (the fast pre-claim first response).
|
|
2476
|
+
*/
|
|
2477
|
+
export async function augmentOnDeckWithGatingBlockers(result, args = {}) {
|
|
2478
|
+
if (!result || result.error) return;
|
|
2479
|
+
if (args.summary === true || args.summary === 'true') return;
|
|
2480
|
+
const targetId =
|
|
2481
|
+
result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
|
|
2482
|
+
if (!targetId) return;
|
|
2483
|
+
|
|
2484
|
+
let res;
|
|
2485
|
+
try {
|
|
2486
|
+
res = await consoleFetch(`/api/specs/${encodeURIComponent(targetId)}/blockers?unresolved=true`);
|
|
2487
|
+
} catch {
|
|
2488
|
+
return; // never let the blocker probe break the on-deck brief
|
|
2489
|
+
}
|
|
2490
|
+
if (!res.ok) {
|
|
2491
|
+
if (res.status === 0) {
|
|
2492
|
+
appendToOnDeckPrompts(
|
|
2493
|
+
result,
|
|
2494
|
+
`\n\n---\n\n> ℹ️ Blocker check skipped — start the spex-console dev server to see gating prerequisites for \`${targetId}\`.`,
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
const gating = Array.isArray(res.body?.blockers)
|
|
2500
|
+
? res.body.blockers.filter((b) => b.gating)
|
|
2501
|
+
: [];
|
|
2502
|
+
result.gating_blockers = gating;
|
|
2503
|
+
if (gating.length > 0) {
|
|
2504
|
+
const lines = gating.map(
|
|
2505
|
+
(b) => `- \`${b.id}\` — ${b.name} (status: \`${b.status}\`, ${b.category})`,
|
|
2506
|
+
);
|
|
2507
|
+
const first = gating[0];
|
|
2508
|
+
const doFirst =
|
|
2509
|
+
first && first.status !== 'missing'
|
|
2510
|
+
? ` **Do this first:** on your confirmation, \`heyspex_pin_set { spec_id: "${first.id}" }\` — a freeze-safe scoped pin change (overlays the snapshot, never rewrites it).`
|
|
2511
|
+
: '';
|
|
2512
|
+
appendToOnDeckPrompts(
|
|
2513
|
+
result,
|
|
2514
|
+
`\n\n---\n\n## ⚠️ Unmet gating prerequisites (${gating.length})\n\n${lines.join('\n')}\n\nThese hard-gate \`${targetId}\` — the same set the console On-Deck overlay shows. Run \`/hs-resolve\` for the full fix brief (Do-this-first / Switch / Defer / Resolve), or clear them before claiming.${doFirst}`,
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* Surface passive stomp-detection warnings on the on-deck brief ([[HS-TASK-FD5O]]).
|
|
2521
|
+
* Reads the same preview endpoint the console would use for a would-be claim.
|
|
2522
|
+
* Best-effort — never blocks the brief when console is unreachable.
|
|
2523
|
+
*/
|
|
2524
|
+
export async function augmentOnDeckWithStompWarnings(result, args = {}) {
|
|
2525
|
+
if (!result || result.error) return;
|
|
2526
|
+
const targetId =
|
|
2527
|
+
result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
|
|
2528
|
+
if (!targetId) return;
|
|
2529
|
+
|
|
2530
|
+
const agentProfile =
|
|
2531
|
+
typeof args.agent_profile === 'string' && args.agent_profile.trim()
|
|
2532
|
+
? args.agent_profile.trim()
|
|
2533
|
+
: undefined;
|
|
2534
|
+
const qs = agentProfile ? `?agent_profile=${encodeURIComponent(agentProfile)}` : '';
|
|
2535
|
+
|
|
2536
|
+
let res;
|
|
2537
|
+
try {
|
|
2538
|
+
res = await consoleFetch(
|
|
2539
|
+
`/api/specs/${encodeURIComponent(targetId)}/stomp-preview${qs}`,
|
|
2540
|
+
);
|
|
2541
|
+
} catch {
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (!res.ok) return;
|
|
2545
|
+
|
|
2546
|
+
const warnings = Array.isArray(res.body?.stomp_warnings) ? res.body.stomp_warnings : [];
|
|
2547
|
+
enrichOnDeckWithStompWarnings(result, warnings);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* Surface passive identity nudges on the on-deck brief ([[HS-TASK-E4OE]]).
|
|
2552
|
+
* Reads the nudge-preview endpoint. Best-effort — never blocks the brief.
|
|
2553
|
+
*/
|
|
2554
|
+
export async function augmentOnDeckWithIdentityNudges(result, args = {}) {
|
|
2555
|
+
if (!result || result.error) return;
|
|
2556
|
+
const targetId =
|
|
2557
|
+
result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
|
|
2558
|
+
if (!targetId) return;
|
|
2559
|
+
|
|
2560
|
+
const agentProfile =
|
|
2561
|
+
typeof args.agent_profile === 'string' && args.agent_profile.trim()
|
|
2562
|
+
? args.agent_profile.trim()
|
|
2563
|
+
: undefined;
|
|
2564
|
+
const params = new URLSearchParams();
|
|
2565
|
+
if (agentProfile) params.set('agent_profile', agentProfile);
|
|
2566
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
2567
|
+
|
|
2568
|
+
let res;
|
|
2569
|
+
try {
|
|
2570
|
+
res = await consoleFetch(
|
|
2571
|
+
`/api/specs/${encodeURIComponent(targetId)}/nudge-preview${qs}`,
|
|
2572
|
+
);
|
|
2573
|
+
} catch {
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
if (!res.ok) return;
|
|
2577
|
+
|
|
2578
|
+
const nudges = Array.isArray(res.body?.identity_nudges) ? res.body.identity_nudges : [];
|
|
2579
|
+
enrichOnDeckWithIdentityNudges(result, nudges);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
/**
|
|
2583
|
+
* Surface depends_on / same-module serialization hints on the on-deck brief
|
|
2584
|
+
* ([[HS-TASK-55VE]]). Best-effort — never blocks the brief when console is
|
|
2585
|
+
* unreachable.
|
|
2586
|
+
*/
|
|
2587
|
+
export async function augmentOnDeckWithParallelismHints(result, args = {}) {
|
|
2588
|
+
if (!result || result.error) return;
|
|
2589
|
+
if (args.summary === true || args.summary === 'true') return;
|
|
2590
|
+
const targetId =
|
|
2591
|
+
result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
|
|
2592
|
+
if (!targetId) return;
|
|
2593
|
+
|
|
2594
|
+
let res;
|
|
2595
|
+
try {
|
|
2596
|
+
res = await consoleFetch(
|
|
2597
|
+
`/api/specs/${encodeURIComponent(targetId)}/parallelism-preview`,
|
|
2598
|
+
);
|
|
2599
|
+
} catch {
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
if (!res.ok) return;
|
|
2603
|
+
|
|
2604
|
+
const hints = Array.isArray(res.body?.parallelism_hints) ? res.body.parallelism_hints : [];
|
|
2605
|
+
enrichOnDeckWithParallelismHints(result, hints);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* Attach the pre-computed sprint (token-budgeted work-group) the claim target
|
|
2610
|
+
* lands in, from the SAME `sprintPlan` the console computes once at the
|
|
2611
|
+
* planning/alignment gate (`GET /api/specs/:id/children` →
|
|
2612
|
+
* `spex-console/src/lib/spec-children.ts`'s `buildSpecChildren` /
|
|
2613
|
+
* `computeSprintPlan`) — HS-ARWE (console half) /
|
|
2614
|
+
* [[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
|
|
2615
|
+
* 7-9 (MCP half): the sprint decision is surfaced to the agent BEFORE
|
|
2616
|
+
* execution begins, never decided by the implementing agent, and re-derives
|
|
2617
|
+
* only when this brief is re-fetched (the gate re-firing) — never silently
|
|
2618
|
+
* mid-implementation.
|
|
2619
|
+
*
|
|
2620
|
+
* Same best-effort HTTP-augment shape as `augmentOnDeckWithGatingBlockers`:
|
|
2621
|
+
* only runs for a task-grain claim target with a resolvable parent epic id;
|
|
2622
|
+
* console-unreachable, a non-ok response, or no matching sprint sets
|
|
2623
|
+
* `result.sprint = null` and appends no line — never throws, never blocks
|
|
2624
|
+
* the brief. A non-task claim target (or a task with no `parent.id`) is left
|
|
2625
|
+
* completely untouched — no `sprint` key at all.
|
|
2626
|
+
*
|
|
2627
|
+
* `fetchImpl` defaults to the module's real `consoleFetch` (unchanged for
|
|
2628
|
+
* every production call site); tests inject a stub, same DI seam as
|
|
2629
|
+
* `resolveNextResolutionPayload(args, { consoleFetch })` in `next-resolution.mjs`.
|
|
2630
|
+
*/
|
|
2631
|
+
export async function attachSprintToOnDeck(result, fetchImpl = consoleFetch) {
|
|
2632
|
+
if (!result || result.error) return;
|
|
2633
|
+
const claimTarget = result.claim_target;
|
|
2634
|
+
if (!claimTarget || claimTarget.level !== 'task') return;
|
|
2635
|
+
const parentId = claimTarget.parent?.id;
|
|
2636
|
+
if (!parentId) return;
|
|
2637
|
+
|
|
2638
|
+
let res;
|
|
2639
|
+
try {
|
|
2640
|
+
res = await fetchImpl(`/api/specs/${encodeURIComponent(parentId)}/children`);
|
|
2641
|
+
} catch {
|
|
2642
|
+
result.sprint = null;
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
if (!res.ok) {
|
|
2646
|
+
result.sprint = null;
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
const sprintPlan = res.body?.sprintPlan;
|
|
2651
|
+
if (!sprintPlan || !Array.isArray(sprintPlan.sprints)) {
|
|
2652
|
+
result.sprint = null;
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
// Same case-insensitive id comparison used throughout this file (e.g. the
|
|
2657
|
+
// `.toLowerCase()` level/status checks above) — the console's task ids and
|
|
2658
|
+
// this brief's claim-target id can differ in case.
|
|
2659
|
+
const targetIdLower = String(claimTarget.spec_id || '').toLowerCase();
|
|
2660
|
+
const matchedSprint = sprintPlan.sprints.find(
|
|
2661
|
+
(s) =>
|
|
2662
|
+
Array.isArray(s.taskIds) &&
|
|
2663
|
+
s.taskIds.some((id) => String(id).toLowerCase() === targetIdLower),
|
|
2664
|
+
);
|
|
2665
|
+
if (!matchedSprint) {
|
|
2666
|
+
result.sprint = null;
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const children = Array.isArray(res.body?.children) ? res.body.children : [];
|
|
2671
|
+
const ownRow = children.find((c) => String(c?.id || '').toLowerCase() === targetIdLower);
|
|
2672
|
+
const estimatedTokens =
|
|
2673
|
+
typeof ownRow?.estimatedTokens === 'number' ? ownRow.estimatedTokens : 0;
|
|
2674
|
+
const position =
|
|
2675
|
+
matchedSprint.taskIds.findIndex((id) => String(id).toLowerCase() === targetIdLower) + 1;
|
|
2676
|
+
|
|
2677
|
+
result.sprint = {
|
|
2678
|
+
index: matchedSprint.index,
|
|
2679
|
+
position,
|
|
2680
|
+
size: matchedSprint.taskIds.length,
|
|
2681
|
+
totalTokens: matchedSprint.totalTokens,
|
|
2682
|
+
budget: sprintPlan.budget,
|
|
2683
|
+
overBudget: matchedSprint.overBudget,
|
|
2684
|
+
taskIds: matchedSprint.taskIds,
|
|
2685
|
+
estimatedTokens,
|
|
2686
|
+
};
|
|
2687
|
+
|
|
2688
|
+
// Re-render the SAME markdown builder (`formatClaimBanner`) with the sprint
|
|
2689
|
+
// now known, and splice it into both prompt fields in place of the
|
|
2690
|
+
// sprint-less banner `enrichOnDeckWithClaimTarget` already embedded
|
|
2691
|
+
// (bookended banner in full mode → two occurrences; summary mode → one).
|
|
2692
|
+
// `.split(...).join(...)` replaces every occurrence, unlike `.replace`.
|
|
2693
|
+
const oldBanner = formatClaimBanner(claimTarget, result.alignment_result || {});
|
|
2694
|
+
const newBanner = formatClaimBanner(claimTarget, result.alignment_result || {}, result.sprint);
|
|
2695
|
+
if (oldBanner && newBanner !== oldBanner) {
|
|
2696
|
+
if (typeof result.user_prompt_markdown === 'string') {
|
|
2697
|
+
result.user_prompt_markdown = result.user_prompt_markdown.split(oldBanner).join(newBanner);
|
|
2698
|
+
}
|
|
2699
|
+
if (typeof result.prompt_markdown === 'string') {
|
|
2700
|
+
result.prompt_markdown = result.prompt_markdown.split(oldBanner).join(newBanner);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/** HTTP-backed on-deck augments (gating + stomp + identity nudges + parallelism + sprint). Shared by MCP tool + hs next CLI. */
|
|
2706
|
+
/**
|
|
2707
|
+
* The active claims another agent holds right now — the ones that would make
|
|
2708
|
+
* a launch come back `live_claim` ([[HS-3C9M-A4TX-CAP]]). Stale or expired
|
|
2709
|
+
* claims are relaunchable and drop out; the caller's own session's claims drop
|
|
2710
|
+
* out too (re-fetching a brief after claiming must not skip its own task).
|
|
2711
|
+
* @param {Array<Record<string, any>>} claims GET /api/control/active-claims → claims
|
|
2712
|
+
* @param {string | null | undefined} ownSessionId
|
|
2713
|
+
* @param {number} [nowMs]
|
|
2714
|
+
*/
|
|
2715
|
+
export function selectClaimsHeldByOthers(claims, ownSessionId, nowMs = Date.now()) {
|
|
2716
|
+
const own = ownSessionId ? String(ownSessionId) : '';
|
|
2717
|
+
const out = [];
|
|
2718
|
+
for (const c of Array.isArray(claims) ? claims : []) {
|
|
2719
|
+
if (!c || typeof c.specId !== 'string' || !c.specId) continue;
|
|
2720
|
+
if (c.effectiveStatus === 'stale') continue;
|
|
2721
|
+
if (c.expiresAt) {
|
|
2722
|
+
const t = Date.parse(c.expiresAt);
|
|
2723
|
+
if (Number.isFinite(t) && nowMs >= t) continue;
|
|
2724
|
+
}
|
|
2725
|
+
const sid = c.sessionId ? String(c.sessionId) : '';
|
|
2726
|
+
const short = c.sessionShort ? String(c.sessionShort) : '';
|
|
2727
|
+
if (own && own !== 'local' && (sid === own || (short && own.startsWith(short)))) continue;
|
|
2728
|
+
out.push(c);
|
|
2729
|
+
}
|
|
2730
|
+
return out;
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function claimAgoLabel(iso, nowMs) {
|
|
2734
|
+
const t = Date.parse(String(iso || ''));
|
|
2735
|
+
if (!Number.isFinite(t)) return '';
|
|
2736
|
+
const mins = Math.max(0, Math.round((nowMs - t) / 60000));
|
|
2737
|
+
if (mins < 1) return 'just now';
|
|
2738
|
+
if (mins < 60) return `${mins} min ago`;
|
|
2739
|
+
return `${Math.round(mins / 60)} h ago`;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/**
|
|
2743
|
+
* Markdown appended to the brief: who holds what, which held task(s) this
|
|
2744
|
+
* brief stepped past, and how to put a parallel agent on another task.
|
|
2745
|
+
* Empty when nothing is held.
|
|
2746
|
+
*/
|
|
2747
|
+
export function renderActiveClaimsSection(held, skipped = [], nowMs = Date.now()) {
|
|
2748
|
+
if (!Array.isArray(held) || held.length === 0) return '';
|
|
2749
|
+
const lines = [
|
|
2750
|
+
'',
|
|
2751
|
+
'---',
|
|
2752
|
+
'',
|
|
2753
|
+
`## 👥 Active claims — ${held.length} spec${held.length === 1 ? '' : 's'} held by other agents`,
|
|
2754
|
+
'',
|
|
2755
|
+
];
|
|
2756
|
+
for (const c of held) {
|
|
2757
|
+
const who = `${c.agent || 'agent'} · \`${c.sessionShort || String(c.sessionId || '').slice(0, 8) || '?'}\``;
|
|
2758
|
+
const state = c.effectiveStatus || c.status || 'working';
|
|
2759
|
+
const since = c.claimedAt ? ` · claimed ${claimAgoLabel(c.claimedAt, nowMs)}` : '';
|
|
2760
|
+
const active = c.lastActivityAt ? ` · active ${claimAgoLabel(c.lastActivityAt, nowMs)}` : '';
|
|
2761
|
+
lines.push(`- \`${c.specId}\` — ${who} · ${state}${since}${active}`);
|
|
2762
|
+
}
|
|
2763
|
+
lines.push('');
|
|
2764
|
+
if (Array.isArray(skipped) && skipped.length > 0) {
|
|
2765
|
+
lines.push(
|
|
2766
|
+
`Skipped ${skipped.map((c) => `\`${c.specId}\``).join(', ')} (held above) — this brief presents the next task nobody holds, so a parallel agent can start on it.`,
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
lines.push(
|
|
2770
|
+
'Put more agents on other unheld tasks any time (`hs next <id> --launch`) — the claim ledger and the dispatcher coordinate between them, and a task already held is refused at launch.',
|
|
2771
|
+
);
|
|
2772
|
+
return lines.join('\n');
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* `resolveNextOnDeckPayload`, then step past any task another agent actively
|
|
2777
|
+
* holds ([[HS-3C9M-A4TX-CAP]] — owner ruling 2026-09-02: the live-claim refusal
|
|
2778
|
+
* at launch is right, but the brief should offer the next task that CAN be
|
|
2779
|
+
* launched in parallel and list who holds what). Same `{ error, result }`
|
|
2780
|
+
* shape as the sync resolver. Best-effort: a dead console means no claims are
|
|
2781
|
+
* known and the plain result comes back untouched.
|
|
2782
|
+
*
|
|
2783
|
+
* A spec id the operator named directly is never skipped — they asked for
|
|
2784
|
+
* that one — it just carries the held-by note. When every other launchable
|
|
2785
|
+
* task under the walk is held too, the held task is kept with the note.
|
|
2786
|
+
*/
|
|
2787
|
+
export async function resolveNextOnDeckAroundActiveClaims(
|
|
2788
|
+
vaultRoot,
|
|
2789
|
+
config,
|
|
2790
|
+
args = {},
|
|
2791
|
+
{ fetchImpl = consoleFetch, nowMs = Date.now() } = {},
|
|
2792
|
+
) {
|
|
2793
|
+
const first = resolveNextOnDeckPayload(vaultRoot, config, args);
|
|
2794
|
+
if (!first || first.error || !first.result) return first;
|
|
2795
|
+
|
|
2796
|
+
let claims = [];
|
|
2797
|
+
try {
|
|
2798
|
+
const res = await fetchImpl('/api/control/active-claims', { timeout: 5000 });
|
|
2799
|
+
if (res?.ok && Array.isArray(res.body?.claims)) claims = res.body.claims;
|
|
2800
|
+
} catch {
|
|
2801
|
+
claims = [];
|
|
2802
|
+
}
|
|
2803
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
2804
|
+
const held = selectClaimsHeldByOthers(claims, identity?.sessionId, nowMs);
|
|
2805
|
+
if (held.length === 0) return first;
|
|
2806
|
+
|
|
2807
|
+
const heldById = new Map(held.map((c) => [String(c.specId).toLowerCase(), c]));
|
|
2808
|
+
const requested = args.spec_id ? String(args.spec_id).toLowerCase() : '';
|
|
2809
|
+
let current = first;
|
|
2810
|
+
const skipped = [];
|
|
2811
|
+
const exclude = [];
|
|
2812
|
+
for (let i = 0; i < 25; i += 1) {
|
|
2813
|
+
const ct = current.result?.claim_target;
|
|
2814
|
+
if (!ct || ct.level !== 'task' || !ct.spec_id) break;
|
|
2815
|
+
const id = String(ct.spec_id).toLowerCase();
|
|
2816
|
+
if (id === requested) break;
|
|
2817
|
+
const holder = heldById.get(id);
|
|
2818
|
+
if (!holder) break;
|
|
2819
|
+
const next = resolveNextOnDeckPayload(vaultRoot, config, {
|
|
2820
|
+
...args,
|
|
2821
|
+
exclude_spec_ids: [...exclude, id],
|
|
2822
|
+
});
|
|
2823
|
+
const nct = next?.result?.claim_target;
|
|
2824
|
+
if (!next || next.error || !nct || nct.level !== 'task' || !nct.spec_id) break;
|
|
2825
|
+
skipped.push(holder);
|
|
2826
|
+
exclude.push(id);
|
|
2827
|
+
current = next;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
current.result.active_claims = held;
|
|
2831
|
+
current.result.skipped_held = skipped;
|
|
2832
|
+
appendToOnDeckPrompts(current.result, renderActiveClaimsSection(held, skipped, nowMs));
|
|
2833
|
+
return current;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
export async function applyOnDeckHttpAugments(result, args = {}) {
|
|
2837
|
+
await augmentOnDeckWithGatingBlockers(result, args);
|
|
2838
|
+
await augmentOnDeckWithStompWarnings(result, args);
|
|
2839
|
+
await augmentOnDeckWithIdentityNudges(result, args);
|
|
2840
|
+
await augmentOnDeckWithParallelismHints(result, args);
|
|
2841
|
+
await attachSprintToOnDeck(result);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* [[HS-T41O-NZ8S-DIAG]]: resolve the session's vault WRITE mode once at the
|
|
2846
|
+
* async handler boundary and thread it in as `__vault_write_mode` so the
|
|
2847
|
+
* synchronous brief composer can route the missing-`summary:` authoring
|
|
2848
|
+
* phase to the console (cloud) or the file (local). Never throws — an
|
|
2849
|
+
* unresolvable mode leaves the arg unset and the composer falls back to the
|
|
2850
|
+
* read-tier / file-on-disk signals.
|
|
2851
|
+
*/
|
|
2852
|
+
export async function injectVaultWriteMode(args = {}, resolveWriteMode = resolveVaultWriteMode) {
|
|
2853
|
+
if (args.__vault_write_mode === 'cloud' || args.__vault_write_mode === 'local') return args;
|
|
2854
|
+
try {
|
|
2855
|
+
const mode = await resolveWriteMode();
|
|
2856
|
+
if (mode === 'cloud' || mode === 'local') return { ...args, __vault_write_mode: mode };
|
|
2857
|
+
} catch {
|
|
2858
|
+
/* fall through — the composer has its own signals */
|
|
2859
|
+
}
|
|
2860
|
+
return args;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
/**
|
|
2864
|
+
* Degrade-don't-die wrapper: when any cloud loader inside the brief fell back
|
|
2865
|
+
* to the local clone ({@link readViaSelector}), the response JSON carries
|
|
2866
|
+
* `degraded: true, source: 'disk', cloud_error, warning` at its top level.
|
|
2867
|
+
*/
|
|
2868
|
+
export async function handleNextOnDeck(vaultRoot, config, args) {
|
|
2869
|
+
const { value, degraded } = await withDegradationScope(() => handleNextOnDeckInner(vaultRoot, config, args));
|
|
2870
|
+
if (!degraded || !value || value.isError) return value;
|
|
2871
|
+
try {
|
|
2872
|
+
const first = value.content?.[0];
|
|
2873
|
+
const parsed = JSON.parse(first.text);
|
|
2874
|
+
return { ...value, content: [{ ...first, text: JSON.stringify({ ...parsed, ...degraded }, null, 2) }] };
|
|
2875
|
+
} catch {
|
|
2876
|
+
return value;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
async function handleNextOnDeckInner(vaultRoot, config, args) {
|
|
2881
|
+
// Resolve the Neon layer-1 pin first (async), then the frozen milestone
|
|
2882
|
+
// snapshot order (async, [[HS-TASK-JHIA]]), then run the sync resolver with
|
|
2883
|
+
// both threaded in via `neon_pin_spec_id` / `frozen_epic_order`. See
|
|
2884
|
+
// `injectNeonFocusPin` + `injectFrozenMilestoneOrder`.
|
|
2885
|
+
const resolvedArgs = await injectVaultWriteMode(
|
|
2886
|
+
await injectCloudWipState(
|
|
2887
|
+
vaultRoot,
|
|
2888
|
+
await injectFrozenMilestoneOrder(await injectNeonFocusPin(args)),
|
|
2889
|
+
),
|
|
2890
|
+
);
|
|
2891
|
+
|
|
2892
|
+
// [[HS-DJWP-MCPC-TASK]] "Scheduler cohort + pillar tier map" family: in
|
|
2893
|
+
// cloud reliance, pre-fetch both over HTTP before the synchronous resolver
|
|
2894
|
+
// runs, so it never falls back to `readdirSync`ing `specs/{tasks,epics,
|
|
2895
|
+
// milestones,diagnoses}` / `specs/pillars` (which a no-vault-checkout
|
|
2896
|
+
// session cannot do). Skipped for the direct spec_id lookup path (it never
|
|
2897
|
+
// calls computeGlobalSchedulerOrder). A dead console here is the ONE
|
|
2898
|
+
// decision point (readViaSelector, inside the *ViaTier loaders) surfacing
|
|
2899
|
+
// CONSOLE_UNREACHABLE_CLOUD_MODE — never a silent disk fallback.
|
|
2900
|
+
let cloudArgs = resolvedArgs;
|
|
2901
|
+
if (!resolvedArgs.spec_id) {
|
|
2902
|
+
const tier = await resolveReadTier();
|
|
2903
|
+
if (tier === 'neon') {
|
|
2904
|
+
const consoleUrl = effectiveConsoleUrl();
|
|
2905
|
+
try {
|
|
2906
|
+
const [schedulerRows, pillarTierById] = await Promise.all([
|
|
2907
|
+
loadVaultSpecsForSchedulerViaTier(vaultRoot, { tier, consoleUrl }),
|
|
2908
|
+
loadPillarTierMapViaTier(vaultRoot, { tier, consoleUrl }),
|
|
2909
|
+
]);
|
|
2910
|
+
cloudArgs = {
|
|
2911
|
+
...resolvedArgs,
|
|
2912
|
+
__cloud_scheduler_rows: schedulerRows,
|
|
2913
|
+
__cloud_pillar_tier_by_id: pillarTierById,
|
|
2914
|
+
};
|
|
2915
|
+
} catch (err) {
|
|
2916
|
+
return {
|
|
2917
|
+
isError: true,
|
|
2918
|
+
content: [{
|
|
2919
|
+
type: 'text',
|
|
2920
|
+
text: JSON.stringify({
|
|
2921
|
+
error: err instanceof ConsoleUnreachableCloudModeError ? err.name : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
|
|
2922
|
+
message: String(err?.message ?? err),
|
|
2923
|
+
}),
|
|
2924
|
+
}],
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
} else {
|
|
2929
|
+
// [[HS-DJWP-MCPC-TASK]] round 2, AC1 "general spec load": the direct
|
|
2930
|
+
// `spec_id` lookup branch of `resolveNextOnDeckPayload` takes the SAME
|
|
2931
|
+
// pre-fetch-and-inject treatment as the scheduler cohort above — see
|
|
2932
|
+
// `__cloud_direct_spec` there for why that branch can't be made async
|
|
2933
|
+
// directly. Once the spec is known, also pre-fetch design-memory
|
|
2934
|
+
// decisions when its tags trigger them (`__cloud_design_decisions`,
|
|
2935
|
+
// best-effort per `loadDesignMemoriesViaTier`).
|
|
2936
|
+
//
|
|
2937
|
+
// [[HS-H227-MCPC-TASK]]: `deriveTaskBrief`'s decomposition context
|
|
2938
|
+
// (`extractDecompositionContext`'s `loadRef` — related links, the
|
|
2939
|
+
// parent-epic/milestone climb + milestone siblings) and
|
|
2940
|
+
// `enrichOnDeckWithClaimTarget`'s parent-contract lookup (same `loadTask`
|
|
2941
|
+
// shape) ALSO get the pre-fetch-and-inject treatment here —
|
|
2942
|
+
// `prefetchCloudTaskGraph` walks that bounded closure over HTTP and the
|
|
2943
|
+
// result rides in as `__cloud_task_graph`, consulted by
|
|
2944
|
+
// `loadTaskViaGraph` instead of `loadTask` at those two sites. The
|
|
2945
|
+
// readiness-floor settings file stays disk-only (machine-local per the
|
|
2946
|
+
// epic rule) — not part of this pre-fetch.
|
|
2947
|
+
const tier = await resolveReadTier();
|
|
2948
|
+
if (tier === 'neon') {
|
|
2949
|
+
const consoleUrl = effectiveConsoleUrl();
|
|
2950
|
+
try {
|
|
2951
|
+
const directSpec = await loadSpecByIdViaTier(vaultRoot, resolvedArgs.spec_id, { tier, consoleUrl });
|
|
2952
|
+
cloudArgs = { ...resolvedArgs, __cloud_direct_spec: directSpec };
|
|
2953
|
+
if (directSpec) {
|
|
2954
|
+
const uiTags = detectUITags(directSpec.frontmatter || {});
|
|
2955
|
+
if (uiTags.length > 0) {
|
|
2956
|
+
cloudArgs.__cloud_design_decisions = await loadDesignMemoriesViaTier(vaultRoot, uiTags, { tier, consoleUrl });
|
|
2957
|
+
}
|
|
2958
|
+
cloudArgs.__cloud_task_graph = await prefetchCloudTaskGraph(vaultRoot, resolvedArgs.spec_id, directSpec, {
|
|
2959
|
+
tier,
|
|
2960
|
+
consoleUrl,
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
return {
|
|
2965
|
+
isError: true,
|
|
2966
|
+
content: [{
|
|
2967
|
+
type: 'text',
|
|
2968
|
+
text: JSON.stringify({
|
|
2969
|
+
error: err instanceof ConsoleUnreachableCloudModeError ? err.name : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
|
|
2970
|
+
message: String(err?.message ?? err),
|
|
2971
|
+
}),
|
|
2972
|
+
}],
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
const { error, result } = await resolveNextOnDeckAroundActiveClaims(vaultRoot, config, cloudArgs);
|
|
2979
|
+
|
|
2980
|
+
if (error) {
|
|
2981
|
+
return {
|
|
2982
|
+
isError: true,
|
|
2983
|
+
content: [{ type: 'text', text: JSON.stringify(error) }],
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// One detection path (RSSO §5): surface the same gating prereqs as the console
|
|
2988
|
+
// overlay. Best-effort — never breaks the brief if console is unreachable.
|
|
2989
|
+
await applyOnDeckHttpAugments(result, resolvedArgs);
|
|
2990
|
+
await attachConsoleStatusToOnDeck(result);
|
|
2991
|
+
|
|
2992
|
+
return {
|
|
2993
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
export function nextReviewDeps() {
|
|
2998
|
+
return { findSpecFile, loadSpec, loadRoadmap, loadTask };
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
export function handleNextReview(vaultRoot, config, args) {
|
|
3002
|
+
const { error, result } = resolveNextReviewPayload(
|
|
3003
|
+
vaultRoot,
|
|
3004
|
+
config,
|
|
3005
|
+
args,
|
|
3006
|
+
nextReviewDeps(),
|
|
3007
|
+
);
|
|
3008
|
+
|
|
3009
|
+
if (error) {
|
|
3010
|
+
return {
|
|
3011
|
+
isError: true,
|
|
3012
|
+
content: [{ type: 'text', text: JSON.stringify(error) }],
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
return {
|
|
3017
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/**
|
|
3022
|
+
* heyspex_next_resolution — the agent face of the Resolution lane. HTTP-only:
|
|
3023
|
+
* the resolver reads the console aggregator (the one detection path) via
|
|
3024
|
+
* consoleFetch; we never re-detect here. Async because consoleFetch is async —
|
|
3025
|
+
* the dispatch switch is sync but its caller awaits returned Promises.
|
|
3026
|
+
*/
|
|
3027
|
+
export async function handleNextResolution(args = {}) {
|
|
3028
|
+
const { error, result } = await resolveNextResolutionPayload(args, { consoleFetch });
|
|
3029
|
+
if (error) {
|
|
3030
|
+
return {
|
|
3031
|
+
isError: true,
|
|
3032
|
+
content: [{ type: 'text', text: JSON.stringify(error, null, 2) }],
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
return {
|
|
3036
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/**
|
|
3041
|
+
* Live console liveness stamped on every /hs-next response so agents cannot
|
|
3042
|
+
* infer "server down" from conditional failure_modes alone. Uses the same
|
|
3043
|
+
* transport as claim/file_spec (fetch → /api/version).
|
|
3044
|
+
*/
|
|
3045
|
+
export async function attachConsoleStatusToOnDeck(result) {
|
|
3046
|
+
if (!result || result.error) return;
|
|
3047
|
+
try {
|
|
3048
|
+
const probe = await consoleFetch('/api/version', { timeout: 4000 });
|
|
3049
|
+
let url = effectiveConsoleUrl();
|
|
3050
|
+
if (probe.url) {
|
|
3051
|
+
try {
|
|
3052
|
+
url = new URL(probe.url).origin;
|
|
3053
|
+
} catch {
|
|
3054
|
+
// keep effectiveConsoleUrl() fallback
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
const consoleStatus = {
|
|
3058
|
+
reachable: probe.ok,
|
|
3059
|
+
url,
|
|
3060
|
+
checked_at: new Date().toISOString(),
|
|
3061
|
+
...(probe.ok
|
|
3062
|
+
? {}
|
|
3063
|
+
: {
|
|
3064
|
+
error_code: consoleErrorCode(probe, 'CONSOLE_PROBE_FAILED'),
|
|
3065
|
+
error: probe.error || null,
|
|
3066
|
+
}),
|
|
3067
|
+
};
|
|
3068
|
+
result.console_status = consoleStatus;
|
|
3069
|
+
if (result.agent_dispatch && typeof result.agent_dispatch === 'object') {
|
|
3070
|
+
result.agent_dispatch.console_status = consoleStatus;
|
|
3071
|
+
result.agent_dispatch.console_connectivity_note = probe.ok
|
|
3072
|
+
? 'Console was reachable when this brief was built — do NOT tell the user the dev server is down unless a tool call returns CONSOLE_UNREACHABLE after this timestamp.'
|
|
3073
|
+
: 'Console probe failed when this brief was built — call heyspex_dev_server_status before claiming the server is down.';
|
|
3074
|
+
}
|
|
3075
|
+
} catch {
|
|
3076
|
+
result.console_status = {
|
|
3077
|
+
reachable: false,
|
|
3078
|
+
checked_at: new Date().toISOString(),
|
|
3079
|
+
error: 'probe_exception',
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
/**
|
|
3085
|
+
* [[HS-TASK-JHIA]] — pre-fetch the FROZEN milestone snapshot from the console
|
|
3086
|
+
* (Neon-sourced) at the async handler boundary, mirroring `injectNeonFocusPin`.
|
|
3087
|
+
*
|
|
3088
|
+
* The vendor cli has no Neon client, so the frozen epic order can only arrive
|
|
3089
|
+
* over HTTP. We resolve the active milestone server-side via the `active`
|
|
3090
|
+
* sentinel and thread the frozen epic order into args as `frozen_epic_order`, so
|
|
3091
|
+
* the sync resolver reorders the milestone-drill candidate epics by it — making
|
|
3092
|
+
* `/hs-next`'s seed epic agree with the console What's Next / On Deck surfaces.
|
|
3093
|
+
*
|
|
3094
|
+
* Best-effort: a disabled flag (`{ enabled: false }`), missing snapshot, or an
|
|
3095
|
+
* unreachable console leaves the order unset and `/hs-next` keeps live scheduler
|
|
3096
|
+
* order — the honest `CONSOLE_UNREACHABLE` degradation ([[HS-TASK-JHIA]] AC #2).
|
|
3097
|
+
* No `.heyspex/*` file fallback is ever consulted ([[no-fallbacks-by-default]]).
|
|
3098
|
+
*/
|
|
3099
|
+
export async function injectFrozenMilestoneOrder(args = {}) {
|
|
3100
|
+
if (args.ignore_focus === true || args.ignore_focus === 'true') return args;
|
|
3101
|
+
if (args.spec_id) return args;
|
|
3102
|
+
let res;
|
|
3103
|
+
try {
|
|
3104
|
+
res = await consoleFetch('/api/scheduler/milestone-snapshot/active');
|
|
3105
|
+
} catch {
|
|
3106
|
+
return args; // unreachable → live order
|
|
3107
|
+
}
|
|
3108
|
+
if (!res || !res.ok || !res.body || res.body.enabled !== true) return args;
|
|
3109
|
+
const snapshot = res.body.snapshot;
|
|
3110
|
+
if (!snapshot || !Array.isArray(snapshot.epicOrder)) return args;
|
|
3111
|
+
const frozen = snapshot.epicOrder
|
|
3112
|
+
.slice()
|
|
3113
|
+
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
3114
|
+
.map((e) => e.epic_id)
|
|
3115
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
3116
|
+
const appended = snapshot.appendedAfterSnapshot?.epics;
|
|
3117
|
+
if (Array.isArray(appended)) {
|
|
3118
|
+
for (const id of appended) {
|
|
3119
|
+
if (typeof id === 'string' && id.length > 0) frozen.push(id);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
if (frozen.length === 0) return args;
|
|
3123
|
+
return { ...args, frozen_epic_order: frozen };
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
/**
|
|
3127
|
+
* heyspex_suggest_pillar — rank the pillar taxonomy against a draft spec.
|
|
3128
|
+
*
|
|
3129
|
+
* Thin relay over POST /api/pillars/suggest (the console owns the charter corpus and the
|
|
3130
|
+
* scorer). Read-only by construction: there is no code path from here to creating a
|
|
3131
|
+
* pillar, because a pillar is a permanent taxonomy change that only the owner approves
|
|
3132
|
+
* ([[pillar-assignment-protocol]] §"Proposing a new pillar").
|
|
3133
|
+
*/
|
|
3134
|
+
export async function handleSuggestPillar(args = {}) {
|
|
3135
|
+
const subject = [args.subject, args.name, args.summary, Array.isArray(args.tags) ? args.tags.join(' ') : '']
|
|
3136
|
+
.filter((v) => typeof v === 'string' && v.trim())
|
|
3137
|
+
.join(' ')
|
|
3138
|
+
.trim();
|
|
3139
|
+
if (!subject) {
|
|
3140
|
+
return {
|
|
3141
|
+
isError: true,
|
|
3142
|
+
content: [{
|
|
3143
|
+
type: 'text',
|
|
3144
|
+
text: JSON.stringify({
|
|
3145
|
+
error: 'INVALID_ARGS',
|
|
3146
|
+
message: 'Pass the draft spec\'s `name` (ideally with `summary` and `tags`), or a freeform `subject`. Scoring needs the spec\'s own words.',
|
|
3147
|
+
}, null, 2),
|
|
3148
|
+
}],
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
const result = await consoleFetch('/api/pillars/suggest', {
|
|
3153
|
+
method: 'POST',
|
|
3154
|
+
body: {
|
|
3155
|
+
name: typeof args.name === 'string' ? args.name : undefined,
|
|
3156
|
+
summary: typeof args.summary === 'string' ? args.summary : undefined,
|
|
3157
|
+
tags: Array.isArray(args.tags) ? args.tags : undefined,
|
|
3158
|
+
subject: typeof args.subject === 'string' ? args.subject : undefined,
|
|
3159
|
+
shortlist: typeof args.shortlist === 'number' ? args.shortlist : undefined,
|
|
3160
|
+
},
|
|
3161
|
+
});
|
|
3162
|
+
if (!result.ok) {
|
|
3163
|
+
const payload = await enrichConsoleFailurePayloadAsync(result, {
|
|
3164
|
+
error: consoleErrorCode(result, 'SUGGEST_PILLAR_FAILED'),
|
|
3165
|
+
message: consoleErrorMessage(result, `POST /api/pillars/suggest returned ${result.status}`),
|
|
3166
|
+
status: result.status,
|
|
3167
|
+
url: result.url,
|
|
3168
|
+
console_response: result.body,
|
|
3169
|
+
});
|
|
3170
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
3171
|
+
}
|
|
3172
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.body, null, 2) }] };
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
/**
|
|
3176
|
+
* Execute one cross-milestone prerequisite resolution path — [[HS-EPIC-QQBD]].
|
|
3177
|
+
* POST /api/focus/prereq with the chosen `path` (do_first | switch_milestone |
|
|
3178
|
+
* move_in | dispatch | defer). The console owns the deterministic execution; this
|
|
3179
|
+
* tool is the agent-facing face. Success → { ok: true, result }; failures
|
|
3180
|
+
* (including the 409 confirmation_required case for an unconfirmed move_in) pass
|
|
3181
|
+
* the console error body straight through via controlError.
|
|
3182
|
+
*/
|
|
3183
|
+
export async function handleResolvePrerequisite(args = {}) {
|
|
3184
|
+
const body = {
|
|
3185
|
+
blocked_id: args.blocked_id,
|
|
3186
|
+
prereq_id: args.prereq_id,
|
|
3187
|
+
path: args.path,
|
|
3188
|
+
actor: 'agent',
|
|
3189
|
+
...(args.auto_return !== undefined ? { auto_return: args.auto_return } : {}),
|
|
3190
|
+
...(args.confirm !== undefined ? { confirm: args.confirm } : {}),
|
|
3191
|
+
...(args.prereq_milestone_id !== undefined
|
|
3192
|
+
? { prereq_milestone_id: args.prereq_milestone_id }
|
|
3193
|
+
: {}),
|
|
3194
|
+
...(args.blocked_milestone_id !== undefined
|
|
3195
|
+
? { blocked_milestone_id: args.blocked_milestone_id }
|
|
3196
|
+
: {}),
|
|
3197
|
+
...(args.return_to !== undefined ? { return_to: args.return_to } : {}),
|
|
3198
|
+
};
|
|
3199
|
+
const result = await consoleFetch('/api/focus/prereq', { method: 'POST', body });
|
|
3200
|
+
if (!result.ok) {
|
|
3201
|
+
return controlError(result, 'PREREQ_RESOLVE_FAILED', {
|
|
3202
|
+
blocked_id: args.blocked_id,
|
|
3203
|
+
prereq_id: args.prereq_id,
|
|
3204
|
+
path: args.path,
|
|
3205
|
+
});
|
|
3206
|
+
}
|
|
3207
|
+
return controlOk(result.body);
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
export const MEMORY_BASELINE_TAGS = ['feedback', 'decision', 'architecture', 'saas'];
|
|
3211
|
+
|
|
3212
|
+
export const MEMORY_INJECTION_LIMIT = 5;
|
|
3213
|
+
|
|
3214
|
+
export const MEMORY_DESCRIPTION_TRUNCATE = 300;
|
|
3215
|
+
|
|
3216
|
+
/**
|
|
3217
|
+
* Detect that a memory's body carries an amendment/deprecation BELOW the part
|
|
3218
|
+
* recall shows, so the surfaced summary can be flagged as possibly-stale.
|
|
3219
|
+
*
|
|
3220
|
+
* Returns a short label naming what was found (e.g. "Amendment 2026-07-02 —
|
|
3221
|
+
* deprecated as a general architecture principle"), or '' when the memory reads
|
|
3222
|
+
* the same all the way down.
|
|
3223
|
+
*
|
|
3224
|
+
* Only content *after* the shown summary counts: a memory whose opening
|
|
3225
|
+
* paragraph already says it was superseded is not misleading, and flagging it
|
|
3226
|
+
* would be noise.
|
|
3227
|
+
*/
|
|
3228
|
+
export function detectMemoryAmendment(body, shownDescription) {
|
|
3229
|
+
try {
|
|
3230
|
+
const shownEnd = shownDescription
|
|
3231
|
+
? body.indexOf(shownDescription.slice(0, 60))
|
|
3232
|
+
: -1;
|
|
3233
|
+
const tail = shownEnd >= 0 ? body.slice(shownEnd + 1) : body;
|
|
3234
|
+
// Headings first — they carry the most precise label. A memory can have
|
|
3235
|
+
// several (an amendment that narrows it, then a later one that retires it);
|
|
3236
|
+
// surface the most severe, since "deprecated" changes whether the memory
|
|
3237
|
+
// applies at all while "extended" only changes its edges.
|
|
3238
|
+
const headings = [...tail.matchAll(/^#{1,6}\s*((?:Amendment|Addendum|Superseded|Deprecat)[^\n]*)$/gim)].map(
|
|
3239
|
+
(m) => m[1].trim().replace(/\s+/g, ' ').slice(0, 120),
|
|
3240
|
+
);
|
|
3241
|
+
if (headings.length > 0) {
|
|
3242
|
+
const retiring = headings.find((h) => /deprecat|supersed|retire|no longer/i.test(h));
|
|
3243
|
+
return retiring ?? headings[headings.length - 1];
|
|
3244
|
+
}
|
|
3245
|
+
// Otherwise look for retirement language in prose.
|
|
3246
|
+
if (/\b(?:no longer governs|is deprecated|now deprecated|superseded by|has been superseded|retired as)\b/i.test(tail)) {
|
|
3247
|
+
return 'amended/superseded later in body';
|
|
3248
|
+
}
|
|
3249
|
+
return '';
|
|
3250
|
+
} catch {
|
|
3251
|
+
return '';
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
/**
|
|
3256
|
+
* Score vault memories by tag-overlap with a primary tag set + baseline pool.
|
|
3257
|
+
* Returns the top-K memories as `{ source, name, link, description, score }`
|
|
3258
|
+
* entries. Source is always "vault" for now (specs/memory/); agent-local
|
|
3259
|
+
* memory injection is Phase 2.
|
|
3260
|
+
*
|
|
3261
|
+
* Deterministic: same inputs → same output ordering (sort is stable by
|
|
3262
|
+
* score-desc then name-asc), so the prompt cache stays warm on identical calls.
|
|
3263
|
+
*/
|
|
3264
|
+
export function scoreRelevantMemories(vaultRoot, options = {}) {
|
|
3265
|
+
const primaryTags = Array.isArray(options.primaryTags) ? options.primaryTags : [];
|
|
3266
|
+
const baselineTags = Array.isArray(options.baselineTags)
|
|
3267
|
+
? options.baselineTags
|
|
3268
|
+
: MEMORY_BASELINE_TAGS;
|
|
3269
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0
|
|
3270
|
+
? options.limit
|
|
3271
|
+
: MEMORY_INJECTION_LIMIT;
|
|
3272
|
+
|
|
3273
|
+
const memoryDir = join(vaultRoot, 'specs', 'memory');
|
|
3274
|
+
if (!existsSync(memoryDir)) return { header: null, items: [] };
|
|
3275
|
+
|
|
3276
|
+
// Lower-case query set for case-insensitive overlap.
|
|
3277
|
+
const querySet = new Set(
|
|
3278
|
+
[...primaryTags, ...baselineTags].map((t) => String(t).toLowerCase()),
|
|
3279
|
+
);
|
|
3280
|
+
if (querySet.size === 0) return { header: null, items: [] };
|
|
3281
|
+
|
|
3282
|
+
const subdirs = ['lessons', 'decisions', 'references', 'ui-patterns'];
|
|
3283
|
+
const candidates = [];
|
|
3284
|
+
|
|
3285
|
+
for (const sub of subdirs) {
|
|
3286
|
+
const catDir = join(memoryDir, sub);
|
|
3287
|
+
if (!existsSync(catDir)) continue;
|
|
3288
|
+
const files = readdirSync(catDir).filter((f) => f.endsWith('.md'));
|
|
3289
|
+
for (const file of files) {
|
|
3290
|
+
try {
|
|
3291
|
+
const content = readFileSync(join(catDir, file), 'utf8');
|
|
3292
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
3293
|
+
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
|
|
3294
|
+
if (tags.length === 0) continue;
|
|
3295
|
+
const tagSet = new Set(tags.map((t) => String(t).toLowerCase()));
|
|
3296
|
+
let score = 0;
|
|
3297
|
+
for (const q of querySet) if (tagSet.has(q)) score += 1;
|
|
3298
|
+
if (score === 0) continue;
|
|
3299
|
+
const slug = file.replace(/\.md$/, '');
|
|
3300
|
+
const name = frontmatter.name || frontmatter.id || slug;
|
|
3301
|
+
let description = extractSummary(body);
|
|
3302
|
+
if (description.length > MEMORY_DESCRIPTION_TRUNCATE) {
|
|
3303
|
+
description = description.slice(0, MEMORY_DESCRIPTION_TRUNCATE - 1) + '…';
|
|
3304
|
+
}
|
|
3305
|
+
// Amendment awareness. Recall shows only the opening paragraph, so a
|
|
3306
|
+
// memory later amended or deprecated is surfaced by its ORIGINAL claim
|
|
3307
|
+
// — the reverse of what is in force. That is not hypothetical:
|
|
3308
|
+
// [[local-first-applies-beyond-storage]] opens with "we always choose
|
|
3309
|
+
// local first" and, 30 lines below the truncation point, carries
|
|
3310
|
+
// "Amendment 2026-07-02 — deprecated as a general architecture
|
|
3311
|
+
// principle". An agent applying the recalled summary would apply a rule
|
|
3312
|
+
// the owner retired ([[HS-GIXC-CAP-KPZT-heyspex-should-actively-detect-and-preve]]).
|
|
3313
|
+
const amended = detectMemoryAmendment(body, description);
|
|
3314
|
+
if (amended) {
|
|
3315
|
+
description = `${description} ⚠ ${amended} — this summary may not reflect the current ruling; READ THE FULL BODY before applying.`;
|
|
3316
|
+
}
|
|
3317
|
+
candidates.push({
|
|
3318
|
+
source: 'vault',
|
|
3319
|
+
name,
|
|
3320
|
+
link: `[[${slug}]]`,
|
|
3321
|
+
description,
|
|
3322
|
+
amended: amended || undefined,
|
|
3323
|
+
score,
|
|
3324
|
+
});
|
|
3325
|
+
} catch {
|
|
3326
|
+
// Skip unparseable files.
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
candidates.sort((a, b) => {
|
|
3332
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
3333
|
+
return a.name.localeCompare(b.name);
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
return {
|
|
3337
|
+
header: 'Retrieved from prior sessions. Apply at decision points; do not echo verbatim.',
|
|
3338
|
+
items: candidates.slice(0, limit),
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
/**
|
|
3343
|
+
* Surface a `recalled_memories` block on the /hs-next response so the agent
|
|
3344
|
+
* reads the relevant prior-session lessons without having to remember to call
|
|
3345
|
+
* heyspex_recall_memories itself ([[HS-TASK-IMMF]] Part A). Scoring uses the
|
|
3346
|
+
* claimed spec's tags as the primary signal + the baseline pool — so memories
|
|
3347
|
+
* about rule placement, SaaS template cost, etc. always rank as candidates
|
|
3348
|
+
* regardless of which spec is on deck.
|
|
3349
|
+
*
|
|
3350
|
+
* Display-only — never gates. Skipped in summary mode (the full call refetches).
|
|
3351
|
+
*/
|
|
3352
|
+
export function enrichOnDeckWithRecalledMemories(vaultRoot, result, args = {}) {
|
|
3353
|
+
if (!result || result.error) return result;
|
|
3354
|
+
if (args.ignore_memories === true || args.ignore_memories === 'true') return result;
|
|
3355
|
+
|
|
3356
|
+
// Derive the claimed spec's tags. The full response carries the spec frontmatter
|
|
3357
|
+
// through under different keys depending on the path (direct lookup vs scheduler);
|
|
3358
|
+
// fall back to the breadcrumb's leaf id and load the spec on disk if needed.
|
|
3359
|
+
let primaryTags = [];
|
|
3360
|
+
const candidatePaths = [result.spec_path, result.spec_id ? findSpecFile(vaultRoot, result.spec_id) : null].filter(Boolean);
|
|
3361
|
+
for (const p of candidatePaths) {
|
|
3362
|
+
try {
|
|
3363
|
+
const spec = typeof p === 'string' ? loadSpec(vaultRoot, p) : null;
|
|
3364
|
+
if (spec?.frontmatter?.tags && Array.isArray(spec.frontmatter.tags)) {
|
|
3365
|
+
primaryTags = spec.frontmatter.tags;
|
|
3366
|
+
break;
|
|
3367
|
+
}
|
|
3368
|
+
} catch {
|
|
3369
|
+
// Continue to next candidate.
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
const recalled = scoreRelevantMemories(vaultRoot, { primaryTags });
|
|
3374
|
+
if (!recalled.items.length) return result;
|
|
3375
|
+
|
|
3376
|
+
return { ...result, recalled_memories: recalled };
|
|
3377
|
+
}
|