heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
// Claim lifecycle tools: heyspex_claim_spec, report_progress, submit_for_review,
|
|
2
|
+
// release_spec, record_wip, defer_spec, record_alignment, claim_acquire/release/list,
|
|
3
|
+
// claim_file, release_file_claim. Extracted from cli.mjs under
|
|
4
|
+
// [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
|
|
5
|
+
|
|
6
|
+
import { findSpecFile, parseFrontmatter, resolveSpecIdViaTier } from './vault-io.mjs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { childKindFor, getChildRefs, upsertFrontmatterKeys } from './alignment-gate.mjs';
|
|
10
|
+
import { measureDiskLanding, resolveSpecIdForMutation } from './tool-helpers.mjs';
|
|
11
|
+
import { stampSkippedDespiteFindingsOnBreakdownMarker } from './plan-review.mjs';
|
|
12
|
+
import { resolveNextOnDeckPayload } from './on-deck-brief.mjs';
|
|
13
|
+
import { ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier } from './read-path.mjs';
|
|
14
|
+
import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
|
|
15
|
+
import { consoleFetch, controlError, controlOk, enrichConsoleFailurePayloadAsync, postClaimEndpoint } from './console-transport.mjs';
|
|
16
|
+
import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
|
|
17
|
+
import { executeSubmitForReview } from './submit-for-review.mjs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimum length for an alignment override reason. Matches the bar the milestone
|
|
21
|
+
* attach override already uses (promote-research.mjs) so the two overrides in
|
|
22
|
+
* this system hold to one standard.
|
|
23
|
+
*/
|
|
24
|
+
export const ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH = 40;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record that ONE part of a `heyspex_record_alignment` call did not happen, on a
|
|
28
|
+
* call whose main write did ([[HS-IOJW-5YDH-DIAG]] sweep).
|
|
29
|
+
*
|
|
30
|
+
* The old shape put the failure in `payload.skipped_despite_findings_error` and
|
|
31
|
+
* still returned `ok: true` with no `isError`, so a half-happened write read
|
|
32
|
+
* exactly like a write that happened. This marks the payload so
|
|
33
|
+
* `alignmentResult` can flip it to an explicit partial — while keeping every
|
|
34
|
+
* fact that DID land (the read-back alignment stamp, the chained on-deck brief),
|
|
35
|
+
* because the checkpoint is real and must not be re-done.
|
|
36
|
+
*
|
|
37
|
+
* @param {Record<string, unknown>} payload
|
|
38
|
+
* @param {string} part — the sub-write that failed, e.g. 'skipped_despite_findings'
|
|
39
|
+
* @param {unknown} detail — the failure as reported by the writer
|
|
40
|
+
*/
|
|
41
|
+
export function markAlignmentPartial(payload, part, detail) {
|
|
42
|
+
payload.ok = false;
|
|
43
|
+
payload.partial = true;
|
|
44
|
+
payload.alignment_recorded = true;
|
|
45
|
+
payload[`${part}_recorded`] = false;
|
|
46
|
+
payload[`${part}_error`] = detail;
|
|
47
|
+
const failed = Array.isArray(payload.failed_parts) ? payload.failed_parts : [];
|
|
48
|
+
failed.push(part);
|
|
49
|
+
payload.failed_parts = failed;
|
|
50
|
+
payload.partial_message =
|
|
51
|
+
`PARTIAL: the alignment checkpoint for ${payload.spec_id ?? 'this spec'} WAS recorded and read back, ` +
|
|
52
|
+
`but ${failed.join(', ')} was NOT. Do not re-run record_alignment to fix this — the checkpoint is already ` +
|
|
53
|
+
'stamped; act on the per-part error below.';
|
|
54
|
+
return payload;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Wrap a record_alignment payload, surfacing a partial as an error, not a success. */
|
|
58
|
+
export function alignmentResult(payload) {
|
|
59
|
+
const text = JSON.stringify(payload, null, 2);
|
|
60
|
+
return payload.partial === true
|
|
61
|
+
? { isError: true, content: [{ type: 'text', text }] }
|
|
62
|
+
: { content: [{ type: 'text', text }] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stamp the alignment checkpoint onto an epic/milestone's frontmatter so the
|
|
67
|
+
* pre-execution gate clears. Writes only `alignment_reviewed_at` (+ optional
|
|
68
|
+
* summary/override); AGV2/DSIG dropped `alignment_signature` because it was
|
|
69
|
+
* non-deterministic across reads. The next /hs-next sees the fresh timestamp
|
|
70
|
+
* and stays quiet until the fuse expires (or corpus drift surfaces).
|
|
71
|
+
*
|
|
72
|
+
* AGV2/286X: after the write, chain into `resolveNextOnDeckPayload` and embed
|
|
73
|
+
* the result as `next_on_deck` so the agent can execute the next brief without
|
|
74
|
+
* a manual `/hs-next` re-invoke. The chain is best-effort — if it errors, we
|
|
75
|
+
* still return `ok: true` so the recorded checkpoint is not lost.
|
|
76
|
+
* [[pre-execution-alignment-gate]] [[HS-TASK-286X]]
|
|
77
|
+
*/
|
|
78
|
+
export function handleRecordAlignment(vaultRoot, config, args) {
|
|
79
|
+
const specId = args.spec_id;
|
|
80
|
+
if (!specId) {
|
|
81
|
+
return {
|
|
82
|
+
isError: true,
|
|
83
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_SPEC_ID' }) }],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const specPath = findSpecFile(vaultRoot, specId);
|
|
87
|
+
if (!specPath) {
|
|
88
|
+
return {
|
|
89
|
+
isError: true,
|
|
90
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const fullPath = join(vaultRoot, specPath);
|
|
94
|
+
const raw = readFileSync(fullPath, 'utf-8');
|
|
95
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
96
|
+
const grain =
|
|
97
|
+
frontmatter.type === 'milestone' ? 'milestone' : 'epic';
|
|
98
|
+
const childIds = getChildRefs(frontmatter, grain);
|
|
99
|
+
const reviewedAt = new Date().toISOString();
|
|
100
|
+
|
|
101
|
+
const kv = {
|
|
102
|
+
alignment_reviewed_at: reviewedAt,
|
|
103
|
+
};
|
|
104
|
+
if (args.summary) {
|
|
105
|
+
kv.alignment_summary = JSON.stringify(String(args.summary));
|
|
106
|
+
}
|
|
107
|
+
// An override clears a real drift finding, so it has to say something. Any
|
|
108
|
+
// non-empty string used to be enough — "x" cleared the gate — which made the
|
|
109
|
+
// override a formality rather than a decision. Same ≥40-char bar the milestone
|
|
110
|
+
// attach override already carries in promote-research.mjs, and the same reason
|
|
111
|
+
// the plan gate refuses a summary under 80: a question only works when a thin
|
|
112
|
+
// answer is rejected.
|
|
113
|
+
//
|
|
114
|
+
// The `reason.length > 0` guard used to sit on the refusal, which meant a
|
|
115
|
+
// whitespace-only reason trimmed to EMPTY, skipped the refusal entirely, stored
|
|
116
|
+
// nothing, and still came back `overridden: true` — forty spaces cleared a real
|
|
117
|
+
// drift finding and reported that a reason had been recorded
|
|
118
|
+
// ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 3c).
|
|
119
|
+
// Whitespace-only is a thin answer, not an absent one: pass no
|
|
120
|
+
// `override_reason` at all if the review was clean.
|
|
121
|
+
let storedOverride = false;
|
|
122
|
+
if (args.override_reason !== undefined && args.override_reason !== null) {
|
|
123
|
+
const reason = String(args.override_reason).trim();
|
|
124
|
+
if (reason.length < ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH) {
|
|
125
|
+
return {
|
|
126
|
+
isError: true,
|
|
127
|
+
content: [{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text: JSON.stringify({
|
|
130
|
+
error: 'ALIGNMENT_OVERRIDE_REASON_TOO_THIN',
|
|
131
|
+
message:
|
|
132
|
+
`override_reason is ${reason.length} characters after trimming${reason.length === 0 ? ' (it was empty or whitespace only)' : ''} — it must be at least ${ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH} and say WHY the drift finding is a false positive or an intentional supersession. It is stored on the spec as the audit record of that call, so ${reason.length === 0 ? 'blank space' : `"${reason}"`} tells the next reader nothing. Omit override_reason entirely if the review was clean.`,
|
|
133
|
+
override_reason_length: reason.length,
|
|
134
|
+
minimum: ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH,
|
|
135
|
+
}),
|
|
136
|
+
}],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
kv.alignment_override = JSON.stringify(reason);
|
|
140
|
+
storedOverride = true;
|
|
141
|
+
}
|
|
142
|
+
const updated = upsertFrontmatterKeys(raw, kv);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(fullPath, updated, 'utf-8');
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return {
|
|
147
|
+
isError: true,
|
|
148
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'WRITE_FAILED', message: e.message }) }],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Report on the RESULT, not the request ([[HS-IOJW-5YDH-DIAG]] sweep). This is
|
|
153
|
+
// a frontmatter rewrite, so "the file exists" proves nothing — read it back
|
|
154
|
+
// and confirm the stamp is actually in it. The diagnosis' second-order
|
|
155
|
+
// failure was exactly a status flip that answered success over unchanged
|
|
156
|
+
// frontmatter.
|
|
157
|
+
const alignmentLanding = measureDiskLanding(vaultRoot, fullPath);
|
|
158
|
+
try {
|
|
159
|
+
alignmentLanding.stamp_readback = readFileSync(fullPath, 'utf-8').includes(
|
|
160
|
+
`alignment_reviewed_at: ${reviewedAt}`,
|
|
161
|
+
);
|
|
162
|
+
} catch {
|
|
163
|
+
alignmentLanding.stamp_readback = false;
|
|
164
|
+
}
|
|
165
|
+
if (!alignmentLanding.on_disk || alignmentLanding.stamp_readback !== true) {
|
|
166
|
+
return {
|
|
167
|
+
isError: true,
|
|
168
|
+
content: [{
|
|
169
|
+
type: 'text',
|
|
170
|
+
text: JSON.stringify({
|
|
171
|
+
error: 'WRITE_NOT_ON_DISK',
|
|
172
|
+
message:
|
|
173
|
+
`The write reported success but ${specPath} does not carry alignment_reviewed_at: ${reviewedAt} on read-back. ` +
|
|
174
|
+
'Treat the checkpoint as NOT recorded.',
|
|
175
|
+
spec_path: specPath,
|
|
176
|
+
landed: alignmentLanding,
|
|
177
|
+
}, null, 2),
|
|
178
|
+
}],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const resolvedSpecId = frontmatter.id || specId;
|
|
183
|
+
const payload = {
|
|
184
|
+
ok: true,
|
|
185
|
+
spec_id: resolvedSpecId,
|
|
186
|
+
spec_path: specPath,
|
|
187
|
+
landed: alignmentLanding,
|
|
188
|
+
alignment_reviewed_at: reviewedAt,
|
|
189
|
+
child_count: childIds.length,
|
|
190
|
+
// What was actually WRITTEN to the spec, not what was passed in. The old
|
|
191
|
+
// `Boolean(args.override_reason)` reported `true` for a reason that stored
|
|
192
|
+
// nothing ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]]).
|
|
193
|
+
overridden: storedOverride,
|
|
194
|
+
message: `Alignment checkpoint recorded for ${resolvedSpecId} (${childIds.length} ${childKindFor(grain)}${childIds.length === 1 ? '' : 's'}). The response includes the next on-deck brief inline.`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (typeof args.skipped_despite_findings === 'boolean') {
|
|
198
|
+
const stamp = stampSkippedDespiteFindingsOnBreakdownMarker({
|
|
199
|
+
vaultRoot,
|
|
200
|
+
epicId: resolvedSpecId,
|
|
201
|
+
skippedDespiteFindings: args.skipped_despite_findings,
|
|
202
|
+
});
|
|
203
|
+
if (stamp.error) {
|
|
204
|
+
// A FAILURE MUST NOT RIDE ALONG INSIDE A SUCCESS ([[HS-IOJW-5YDH-DIAG]]
|
|
205
|
+
// sweep). This used to set `skipped_despite_findings_error` and still return
|
|
206
|
+
// `ok: true` with no `isError`, so the caller saw a green tool call over a
|
|
207
|
+
// flag that was never stamped. The alignment checkpoint itself DID land (it
|
|
208
|
+
// was read back above), so nothing is re-done and nothing is lost — the
|
|
209
|
+
// payload below carries every recorded fact. What changes is that the caller
|
|
210
|
+
// can now tell a full success from a partial one.
|
|
211
|
+
markAlignmentPartial(payload, 'skipped_despite_findings', stamp);
|
|
212
|
+
} else {
|
|
213
|
+
payload.skipped_despite_findings = stamp.skipped_despite_findings;
|
|
214
|
+
payload.review_marker_path = stamp.review_marker_path;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// [[HS-TASK-286X]] — chain into the next on-deck brief so the agent does not
|
|
219
|
+
// need a manual /hs-next re-invoke. The just-stamped checkpoint clears the
|
|
220
|
+
// alignment gate for the same spec; the focus-pinned path picks up next
|
|
221
|
+
// from the children. Best-effort — chain errors do not corrupt the recorded
|
|
222
|
+
// checkpoint, the field is just omitted.
|
|
223
|
+
if (config) {
|
|
224
|
+
try {
|
|
225
|
+
const chained = resolveNextOnDeckPayload(vaultRoot, config, {
|
|
226
|
+
spec_id: resolvedSpecId,
|
|
227
|
+
roadmap_id: args.roadmap_id,
|
|
228
|
+
});
|
|
229
|
+
if (chained && chained.result) {
|
|
230
|
+
payload.next_on_deck = chained.result;
|
|
231
|
+
} else if (chained && chained.error) {
|
|
232
|
+
payload.next_on_deck_error = chained.error;
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
payload.next_on_deck_error = { error: 'CHAIN_FAILED', message: e.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return alignmentResult(payload);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Cloud-reliant sibling of `handleRecordAlignment` ([[HS-DJWP-MCPC-TASK]]
|
|
244
|
+
* "Spec load + alignment read-back" family, backing `heyspex_record_alignment`).
|
|
245
|
+
*
|
|
246
|
+
* `handleRecordAlignment` itself is left completely untouched — it is
|
|
247
|
+
* imported and called directly (synchronously, no `await`) by
|
|
248
|
+
* `alignment-gate-holes.test.mjs`, so it must keep returning a plain object,
|
|
249
|
+
* never a Promise. This wrapper is a NEW function used only by the live tool
|
|
250
|
+
* dispatch: it resolves the tier ONCE, then either calls the untouched disk
|
|
251
|
+
* path or `handleRecordAlignmentCloud` below. Any thrown error from the cloud
|
|
252
|
+
* branch (network failure, a non-404 non-ok console response) is the ONE
|
|
253
|
+
* decision point (`readViaSelector`) turning it into
|
|
254
|
+
* `CONSOLE_UNREACHABLE_CLOUD_MODE` — never a silent disk fallback.
|
|
255
|
+
*/
|
|
256
|
+
export async function handleRecordAlignmentViaTier(vaultRoot, config, args) {
|
|
257
|
+
const specId = args.spec_id;
|
|
258
|
+
if (!specId) {
|
|
259
|
+
return {
|
|
260
|
+
isError: true,
|
|
261
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_SPEC_ID' }) }],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const tier = await resolveReadTier();
|
|
265
|
+
const consoleUrl = effectiveConsoleUrl();
|
|
266
|
+
try {
|
|
267
|
+
return await readViaSelector({
|
|
268
|
+
tier,
|
|
269
|
+
diskRead: () => handleRecordAlignment(vaultRoot, config, args),
|
|
270
|
+
neonRead: () => handleRecordAlignmentCloud(vaultRoot, config, args, { consoleUrl }),
|
|
271
|
+
routeLabel: 'PUT /api/specs/[id]',
|
|
272
|
+
consoleUrl,
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err instanceof ConsoleUnreachableCloudModeError) {
|
|
276
|
+
return {
|
|
277
|
+
isError: true,
|
|
278
|
+
content: [{ type: 'text', text: JSON.stringify({ error: err.name, message: err.message }) }],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Cloud-tier alignment-checkpoint stamp: `GET /api/specs/resolve` (id →
|
|
287
|
+
* relPath, mirrors `findSpecFile`) + `GET /api/specs/[id]` (load) + `PUT
|
|
288
|
+
* /api/specs/[id]` (write) + a second `GET /api/specs/[id]` (read-back
|
|
289
|
+
* verification — "report on the RESULT, not the request", same discipline
|
|
290
|
+
* the disk path follows). Never touches `readFileSync`/`writeFileSync`.
|
|
291
|
+
*
|
|
292
|
+
* `skipped_despite_findings` and the chained `next_on_deck` brief both stay
|
|
293
|
+
* best-effort / out of scope here: the marker file and the on-deck resolver
|
|
294
|
+
* are disk-based mechanisms this read-path task does not extend to cloud
|
|
295
|
+
* reliance — the chain is already wrapped in try/catch (degrades to
|
|
296
|
+
* `next_on_deck_error`), and the marker request is answered with an explicit
|
|
297
|
+
* "not supported in cloud reliance yet" rather than attempting a local write
|
|
298
|
+
* that would fail with no vault checkout.
|
|
299
|
+
*/
|
|
300
|
+
export async function handleRecordAlignmentCloud(vaultRoot, config, args, { consoleUrl }) {
|
|
301
|
+
const specId = args.spec_id;
|
|
302
|
+
|
|
303
|
+
let storedOverride = false;
|
|
304
|
+
let overrideReasonTrimmed;
|
|
305
|
+
if (args.override_reason !== undefined && args.override_reason !== null) {
|
|
306
|
+
overrideReasonTrimmed = String(args.override_reason).trim();
|
|
307
|
+
if (overrideReasonTrimmed.length < ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH) {
|
|
308
|
+
return {
|
|
309
|
+
isError: true,
|
|
310
|
+
content: [{
|
|
311
|
+
type: 'text',
|
|
312
|
+
text: JSON.stringify({
|
|
313
|
+
error: 'ALIGNMENT_OVERRIDE_REASON_TOO_THIN',
|
|
314
|
+
message:
|
|
315
|
+
`override_reason is ${overrideReasonTrimmed.length} characters after trimming${overrideReasonTrimmed.length === 0 ? ' (it was empty or whitespace only)' : ''} — it must be at least ${ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH} and say WHY the drift finding is a false positive or an intentional supersession. It is stored on the spec as the audit record of that call, so ${overrideReasonTrimmed.length === 0 ? 'blank space' : `"${overrideReasonTrimmed}"`} tells the next reader nothing. Omit override_reason entirely if the review was clean.`,
|
|
316
|
+
override_reason_length: overrideReasonTrimmed.length,
|
|
317
|
+
minimum: ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH,
|
|
318
|
+
}),
|
|
319
|
+
}],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
storedOverride = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const relPath = await resolveSpecIdViaTier(vaultRoot, specId, { tier: 'neon', consoleUrl });
|
|
326
|
+
if (!relPath) {
|
|
327
|
+
return {
|
|
328
|
+
isError: true,
|
|
329
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const getRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
|
|
334
|
+
if (!getRes.ok) {
|
|
335
|
+
throw new Error(`GET /api/specs/${specId} returned ${getRes.status}: ${getRes.error || JSON.stringify(getRes.body ?? {})}`);
|
|
336
|
+
}
|
|
337
|
+
const spec = getRes.body?.spec;
|
|
338
|
+
if (!spec) {
|
|
339
|
+
return {
|
|
340
|
+
isError: true,
|
|
341
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const frontmatter = spec.frontmatter || {};
|
|
346
|
+
const grain = frontmatter.type === 'milestone' ? 'milestone' : 'epic';
|
|
347
|
+
const childIds = getChildRefs(frontmatter, grain);
|
|
348
|
+
const reviewedAt = new Date().toISOString();
|
|
349
|
+
|
|
350
|
+
const updatedFrontmatter = { ...frontmatter, alignment_reviewed_at: reviewedAt };
|
|
351
|
+
if (args.summary) updatedFrontmatter.alignment_summary = String(args.summary);
|
|
352
|
+
if (storedOverride) updatedFrontmatter.alignment_override = overrideReasonTrimmed;
|
|
353
|
+
|
|
354
|
+
const putRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`, {
|
|
355
|
+
method: 'PUT',
|
|
356
|
+
body: { frontmatter: updatedFrontmatter, body: spec.body ?? '' },
|
|
357
|
+
});
|
|
358
|
+
if (!putRes.ok) {
|
|
359
|
+
return {
|
|
360
|
+
isError: true,
|
|
361
|
+
content: [{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: JSON.stringify({
|
|
364
|
+
error: 'WRITE_FAILED',
|
|
365
|
+
message: consoleErrorMessage(putRes, `PUT /api/specs/${specId} returned ${putRes.status}`),
|
|
366
|
+
}),
|
|
367
|
+
}],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Re-read for the stamp, independent of the PUT response.
|
|
372
|
+
const verifyRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
|
|
373
|
+
const stampReadback =
|
|
374
|
+
verifyRes.ok && verifyRes.body?.spec?.frontmatter?.alignment_reviewed_at === reviewedAt;
|
|
375
|
+
const alignmentLanding = {
|
|
376
|
+
tier: 'neon',
|
|
377
|
+
reported_by: 'measured',
|
|
378
|
+
abs_path: null,
|
|
379
|
+
rel_path: relPath,
|
|
380
|
+
on_disk: null, // not applicable in cloud reliance — see `stamp_readback` instead.
|
|
381
|
+
bytes: null,
|
|
382
|
+
stamp_readback: stampReadback,
|
|
383
|
+
};
|
|
384
|
+
if (!stampReadback) {
|
|
385
|
+
return {
|
|
386
|
+
isError: true,
|
|
387
|
+
content: [{
|
|
388
|
+
type: 'text',
|
|
389
|
+
text: JSON.stringify({
|
|
390
|
+
error: 'WRITE_NOT_ON_DISK',
|
|
391
|
+
message:
|
|
392
|
+
`The write reported success but ${specId} does not carry alignment_reviewed_at: ${reviewedAt} on read-back. ` +
|
|
393
|
+
'Treat the checkpoint as NOT recorded.',
|
|
394
|
+
spec_path: relPath,
|
|
395
|
+
landed: alignmentLanding,
|
|
396
|
+
}, null, 2),
|
|
397
|
+
}],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const resolvedSpecId = frontmatter.id || specId;
|
|
402
|
+
const payload = {
|
|
403
|
+
ok: true,
|
|
404
|
+
spec_id: resolvedSpecId,
|
|
405
|
+
spec_path: relPath,
|
|
406
|
+
landed: alignmentLanding,
|
|
407
|
+
alignment_reviewed_at: reviewedAt,
|
|
408
|
+
child_count: childIds.length,
|
|
409
|
+
overridden: storedOverride,
|
|
410
|
+
message: `Alignment checkpoint recorded for ${resolvedSpecId} (${childIds.length} ${childKindFor(grain)}${childIds.length === 1 ? '' : 's'}). The response includes the next on-deck brief inline.`,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
if (typeof args.skipped_despite_findings === 'boolean') {
|
|
414
|
+
// Same rule as the disk tier above, and here the failure is UNCONDITIONAL:
|
|
415
|
+
// cloud reliance cannot stamp the local breakdown-review marker at all, so
|
|
416
|
+
// every caller that passes this flag was being told, in a green response,
|
|
417
|
+
// that it had been recorded.
|
|
418
|
+
markAlignmentPartial(payload, 'skipped_despite_findings', {
|
|
419
|
+
error: 'UNSUPPORTED_IN_CLOUD_RELIANCE',
|
|
420
|
+
message:
|
|
421
|
+
'skipped_despite_findings stamping writes a local breakdown-review marker file and is not yet wired for cloud reliance.',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (config) {
|
|
426
|
+
try {
|
|
427
|
+
const chained = resolveNextOnDeckPayload(vaultRoot, config, {
|
|
428
|
+
spec_id: resolvedSpecId,
|
|
429
|
+
roadmap_id: args.roadmap_id,
|
|
430
|
+
});
|
|
431
|
+
if (chained && chained.result) {
|
|
432
|
+
payload.next_on_deck = chained.result;
|
|
433
|
+
} else if (chained && chained.error) {
|
|
434
|
+
payload.next_on_deck_error = chained.error;
|
|
435
|
+
}
|
|
436
|
+
} catch (e) {
|
|
437
|
+
payload.next_on_deck_error = { error: 'CHAIN_FAILED', message: e.message };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return alignmentResult(payload);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── claim-bound worktree binding ────────────────────────────────────────────
|
|
445
|
+
// [[HS-QDQA]] (epic [[HS-03DO]] "Claim-bound worktree isolation for parallel
|
|
446
|
+
// agent sessions"). A session worktree carries `<root>/.hsewt-owner.json`
|
|
447
|
+
// (`kind: "session"`) and is allocated / released / rebound by
|
|
448
|
+
// dev-factory/lib/worktree-allocator.mjs. The claim lifecycle is where that
|
|
449
|
+
// marker's own lifecycle has to be kept honest:
|
|
450
|
+
//
|
|
451
|
+
// release → mark the worktree RELEASED (never delete — release only makes it
|
|
452
|
+
// eligible for a later `reap`, which still refuses a dirty tree).
|
|
453
|
+
// claim → a BARE spawn keys its marker on `launch-<id>` because no spec was
|
|
454
|
+
// claimed yet; the first real claim REBINDS it to the claimed id
|
|
455
|
+
// (and renames the branch), so the worktree the agent is sitting in
|
|
456
|
+
// is named after the work it is actually doing.
|
|
457
|
+
//
|
|
458
|
+
// Both are BEST-EFFORT by design: the console round trip is the authoritative
|
|
459
|
+
// part of a claim/release, and a missing/broken dev-factory checkout (the MCP
|
|
460
|
+
// server also runs against sibling repos that have no dev-factory at all) must
|
|
461
|
+
// never turn a successful release into a failure. Every outcome — including the
|
|
462
|
+
// failure — is reported under `worktree` in the tool result rather than thrown.
|
|
463
|
+
|
|
464
|
+
/** Real allocator, loaded lazily so a claim never pays for dev-factory. */
|
|
465
|
+
const loadWorktreeAllocator = () => import('../../dev-factory/lib/worktree-allocator.mjs');
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolve the injectable allocator. `deps.worktree` may be the module object
|
|
469
|
+
* itself (a test stub) or a factory returning one; absent, the real module is
|
|
470
|
+
* dynamically imported. Kept as ONE resolver so both handlers below inject the
|
|
471
|
+
* same way.
|
|
472
|
+
*/
|
|
473
|
+
async function resolveWorktreeAllocator(deps = {}) {
|
|
474
|
+
const injected = deps.worktree;
|
|
475
|
+
if (!injected) return loadWorktreeAllocator();
|
|
476
|
+
return typeof injected === 'function' ? injected() : injected;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Default owner-marker basename, mirrored only as a fallback for a stub that
|
|
480
|
+
* does not re-export the allocator's own OWNER_FILE. */
|
|
481
|
+
const DEFAULT_OWNER_FILE = '.hsewt-owner.json';
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Best-effort "this claim is over, the worktree may be reclaimed" stamp.
|
|
485
|
+
* @returns {Promise<{found: boolean, alreadyReleased: boolean} | {error: string}>}
|
|
486
|
+
*/
|
|
487
|
+
export async function markWorktreeReleased(specId, deps = {}) {
|
|
488
|
+
try {
|
|
489
|
+
const wt = await resolveWorktreeAllocator(deps);
|
|
490
|
+
const ctx = wt.makeCtx();
|
|
491
|
+
const result = wt.markReleased(ctx, { specId });
|
|
492
|
+
return {
|
|
493
|
+
found: Boolean(result?.found),
|
|
494
|
+
alreadyReleased: Boolean(result?.alreadyReleased),
|
|
495
|
+
};
|
|
496
|
+
} catch (e) {
|
|
497
|
+
return { error: e?.message ?? String(e) };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Read the owner marker of the worktree this MCP call is running inside, if
|
|
503
|
+
* any. `null` whenever there is no `HEYSPEX_WORKTREE_PATH`, no marker file, or
|
|
504
|
+
* the marker is unreadable — every one of which means "nothing to rebind".
|
|
505
|
+
*/
|
|
506
|
+
function readCurrentWorktreeMarker(deps = {}, ownerFile = DEFAULT_OWNER_FILE) {
|
|
507
|
+
const env = deps.env ?? process.env;
|
|
508
|
+
const root = typeof env.HEYSPEX_WORKTREE_PATH === 'string' ? env.HEYSPEX_WORKTREE_PATH.trim() : '';
|
|
509
|
+
if (!root) return null;
|
|
510
|
+
const read = deps.readFile ?? ((p) => readFileSync(p, 'utf-8'));
|
|
511
|
+
try {
|
|
512
|
+
const parsed = JSON.parse(read(join(root, ownerFile)));
|
|
513
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Re-point a BARE-spawn worktree (`launch-<id>`) at the spec that was just
|
|
521
|
+
* claimed. Returns `null` when this session is not in a worktree, or its marker
|
|
522
|
+
* is already bound to a real spec — so the caller only ever reports a `worktree`
|
|
523
|
+
* field when something actually applied.
|
|
524
|
+
*
|
|
525
|
+
* @returns {Promise<{rebound: boolean, branch: string|null} | {error: string} | null>}
|
|
526
|
+
*/
|
|
527
|
+
export async function rebindLaunchWorktreeForClaim(specId, deps = {}) {
|
|
528
|
+
const env = deps.env ?? process.env;
|
|
529
|
+
const root = typeof env.HEYSPEX_WORKTREE_PATH === 'string' ? env.HEYSPEX_WORKTREE_PATH.trim() : '';
|
|
530
|
+
if (!root) return null; // not a spawned worktree session — never touch the allocator
|
|
531
|
+
try {
|
|
532
|
+
const wt = await resolveWorktreeAllocator(deps);
|
|
533
|
+
const marker = readCurrentWorktreeMarker(deps, wt.OWNER_FILE ?? DEFAULT_OWNER_FILE);
|
|
534
|
+
const from = typeof marker?.specId === 'string' ? marker.specId : '';
|
|
535
|
+
// Only a BARE spawn is rebound. A marker already bound to a real spec id is
|
|
536
|
+
// somebody's live claim, and renaming its branch out from under it is the
|
|
537
|
+
// class of breakage this epic exists to avoid.
|
|
538
|
+
if (!from.startsWith('launch-')) return null;
|
|
539
|
+
if (from === specId) return null;
|
|
540
|
+
const ctx = wt.makeCtx();
|
|
541
|
+
const result = wt.rebind(ctx, { fromSpecId: from, toSpecId: specId });
|
|
542
|
+
return { rebound: Boolean(result?.found), branch: result?.branch ?? null };
|
|
543
|
+
} catch (e) {
|
|
544
|
+
return { error: e?.message ?? String(e) };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function handleClaimSpec(args = {}, deps = {}) {
|
|
549
|
+
if (!args.spec_id || typeof args.spec_id !== 'string') {
|
|
550
|
+
return {
|
|
551
|
+
isError: true,
|
|
552
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
// Resolve the id ONCE ([[HS-05ZW-XHR4-DIAG]]): a bare era-3 file key is what
|
|
556
|
+
// `heyspex_file_spec` hands back, and in cloud reliance only the console can
|
|
557
|
+
// prove it names one spec — so the guard resolves it to the full basename
|
|
558
|
+
// instead of refusing it.
|
|
559
|
+
const claimResolved = await resolveSpecIdForMutation(args.spec_id);
|
|
560
|
+
if (claimResolved.response) return claimResolved.response;
|
|
561
|
+
const claimSpecId = claimResolved.specId;
|
|
562
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
563
|
+
const agentProfile =
|
|
564
|
+
typeof args.agent_profile === 'string' && args.agent_profile.trim()
|
|
565
|
+
? args.agent_profile.trim()
|
|
566
|
+
: undefined;
|
|
567
|
+
const post = deps.postClaimEndpoint ?? postClaimEndpoint;
|
|
568
|
+
const result = await post('POST', claimSpecId, {
|
|
569
|
+
agent: identity.agent,
|
|
570
|
+
sessionId: identity.sessionId,
|
|
571
|
+
...(agentProfile ? { agent_profile: agentProfile } : {}),
|
|
572
|
+
});
|
|
573
|
+
if (!result.ok) {
|
|
574
|
+
const payload = await enrichConsoleFailurePayloadAsync(result, {
|
|
575
|
+
error: consoleErrorCode(result, 'CLAIM_FAILED'),
|
|
576
|
+
message: consoleErrorMessage(result, `Claim endpoint returned ${result.status}`),
|
|
577
|
+
status: result.status,
|
|
578
|
+
url: result.url,
|
|
579
|
+
console_response: result.body,
|
|
580
|
+
attempted: { spec_id: claimSpecId, agent: identity.agent, session_id: identity.sessionId },
|
|
581
|
+
});
|
|
582
|
+
return {
|
|
583
|
+
isError: true,
|
|
584
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const consoleBody = result.body && typeof result.body === 'object' ? result.body : {};
|
|
588
|
+
const stompWarnings = Array.isArray(consoleBody.stomp_warnings) ? consoleBody.stomp_warnings : [];
|
|
589
|
+
const identityNudges = Array.isArray(consoleBody.identity_nudges) ? consoleBody.identity_nudges : [];
|
|
590
|
+
// The claim LANDED — everything below is reporting, and must not be able to
|
|
591
|
+
// unland it ([[HS-QDQA]]).
|
|
592
|
+
const worktree = await rebindLaunchWorktreeForClaim(claimSpecId, deps);
|
|
593
|
+
const payload = {
|
|
594
|
+
spec_id: claimSpecId,
|
|
595
|
+
agent: identity.agent,
|
|
596
|
+
session_id: identity.sessionId,
|
|
597
|
+
...(agentProfile ? { agent_profile: agentProfile } : {}),
|
|
598
|
+
console_url: result.url,
|
|
599
|
+
console_response: result.body,
|
|
600
|
+
...(stompWarnings.length ? { stomp_warnings: stompWarnings } : {}),
|
|
601
|
+
...(identityNudges.length ? { identity_nudges: identityNudges } : {}),
|
|
602
|
+
...(worktree ? { worktree } : {}),
|
|
603
|
+
};
|
|
604
|
+
return {
|
|
605
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Closed work-stage vocabulary. SSOT is spex-console/src/lib/work-stages.ts +
|
|
610
|
+
// the execute procedure; this list is the agent-facing mirror. The console route
|
|
611
|
+
// (PATCH /api/specs/:id/claim/status) is the authoritative validator, so a drift
|
|
612
|
+
// here is caught there rather than silently accepted.
|
|
613
|
+
export const WORK_STAGES = ['Grounding', 'Breaking down', 'Drafting', 'Validating', 'Testing', 'Reviewing'];
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Build the PATCH /api/specs/:id/claim/status request body from
|
|
617
|
+
* heyspex_report_progress args + the resolved caller identity. Pure and
|
|
618
|
+
* exported so the sessionId-forwarding contract is unit-testable without a
|
|
619
|
+
* live console — [[HS-0BQ8-3TY6-TASK-send-the-agent-s-session-with-every]].
|
|
620
|
+
* Every sibling handler in this file already sends `identity.sessionId` on
|
|
621
|
+
* its PATCH/POST body; this brings handleReportProgress in line so the
|
|
622
|
+
* console's claim-fence guard (which only engages when a session rides the
|
|
623
|
+
* request) actually engages on the one path every agent uses.
|
|
624
|
+
*/
|
|
625
|
+
export function buildReportProgressBody(args = {}, acProgress, identity) {
|
|
626
|
+
const body = { status: 'working', currentStage: args.current_stage, sessionId: identity.sessionId };
|
|
627
|
+
if (acProgress !== undefined) body.acProgress = acProgress;
|
|
628
|
+
return body;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function handleReportProgress(args = {}) {
|
|
632
|
+
if (!args.spec_id || typeof args.spec_id !== 'string') {
|
|
633
|
+
return {
|
|
634
|
+
isError: true,
|
|
635
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (typeof args.current_stage !== 'string' || !WORK_STAGES.includes(args.current_stage)) {
|
|
639
|
+
return {
|
|
640
|
+
isError: true,
|
|
641
|
+
content: [
|
|
642
|
+
{
|
|
643
|
+
type: 'text',
|
|
644
|
+
text: JSON.stringify({
|
|
645
|
+
error: 'INVALID_ARGS',
|
|
646
|
+
message: `current_stage must be one of: ${WORK_STAGES.join(', ')}`,
|
|
647
|
+
}),
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
let acProgress;
|
|
653
|
+
if (args.ac_progress !== undefined && args.ac_progress !== null) {
|
|
654
|
+
if (typeof args.ac_progress !== 'number' || !Number.isInteger(args.ac_progress) || args.ac_progress < 0) {
|
|
655
|
+
return {
|
|
656
|
+
isError: true,
|
|
657
|
+
content: [
|
|
658
|
+
{
|
|
659
|
+
type: 'text',
|
|
660
|
+
text: JSON.stringify({ error: 'INVALID_ARGS', message: 'ac_progress must be a non-negative integer' }),
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
acProgress = args.ac_progress;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Same guard as claim/submit/defer ([[HS-05ZW-XHR4-DIAG]] surface sweep): a
|
|
669
|
+
// progress PATCH is a claim-scoped mutation, so an id that names a whole
|
|
670
|
+
// family must not be forwarded to pick an arbitrary member.
|
|
671
|
+
const progressResolved = await resolveSpecIdForMutation(args.spec_id);
|
|
672
|
+
if (progressResolved.response) return progressResolved.response;
|
|
673
|
+
const progressSpecId = progressResolved.specId;
|
|
674
|
+
|
|
675
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
676
|
+
const body = buildReportProgressBody(args, acProgress, identity);
|
|
677
|
+
|
|
678
|
+
const result = await consoleFetch(
|
|
679
|
+
`/api/specs/${encodeURIComponent(progressSpecId)}/claim/status`,
|
|
680
|
+
{ method: 'PATCH', body },
|
|
681
|
+
);
|
|
682
|
+
if (!result.ok) {
|
|
683
|
+
const payload = {
|
|
684
|
+
error: consoleErrorCode(result, 'REPORT_PROGRESS_FAILED'),
|
|
685
|
+
message: consoleErrorMessage(result, `Claim status endpoint returned ${result.status}`),
|
|
686
|
+
status: result.status,
|
|
687
|
+
url: result.url,
|
|
688
|
+
console_response: result.body,
|
|
689
|
+
attempted: { spec_id: progressSpecId, current_stage: args.current_stage, ac_progress: acProgress },
|
|
690
|
+
};
|
|
691
|
+
return {
|
|
692
|
+
isError: true,
|
|
693
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
content: [
|
|
698
|
+
{
|
|
699
|
+
type: 'text',
|
|
700
|
+
text: JSON.stringify(
|
|
701
|
+
{ spec_id: progressSpecId, current_stage: args.current_stage, ac_progress: acProgress, console_response: result.body },
|
|
702
|
+
null,
|
|
703
|
+
2,
|
|
704
|
+
),
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export async function handleSubmitForReview(vaultRoot, args = {}) {
|
|
711
|
+
const submitResolved = await resolveSpecIdForMutation(args.spec_id);
|
|
712
|
+
if (submitResolved.response) return submitResolved.response;
|
|
713
|
+
const result = await executeSubmitForReview({
|
|
714
|
+
vaultRoot,
|
|
715
|
+
consoleFetch,
|
|
716
|
+
postClaimEndpoint,
|
|
717
|
+
args: { ...args, spec_id: submitResolved.specId },
|
|
718
|
+
});
|
|
719
|
+
if (result.error) {
|
|
720
|
+
return {
|
|
721
|
+
isError: true,
|
|
722
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export async function handleReleaseSpec(args = {}, deps = {}) {
|
|
731
|
+
if (!args.spec_id || typeof args.spec_id !== 'string') {
|
|
732
|
+
return {
|
|
733
|
+
isError: true,
|
|
734
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// Same guard as claim ([[HS-05ZW-XHR4-DIAG]] surface sweep): DELETE-ing the
|
|
738
|
+
// claim of an arbitrary family member is the same class of harm as claiming
|
|
739
|
+
// one, so release goes through the shared resolver too.
|
|
740
|
+
const releaseResolved = await resolveSpecIdForMutation(args.spec_id);
|
|
741
|
+
if (releaseResolved.response) return releaseResolved.response;
|
|
742
|
+
const releaseSpecId = releaseResolved.specId;
|
|
743
|
+
const post = deps.postClaimEndpoint ?? postClaimEndpoint;
|
|
744
|
+
const result = await post('DELETE', releaseSpecId);
|
|
745
|
+
if (!result.ok) {
|
|
746
|
+
const payload = {
|
|
747
|
+
error: consoleErrorCode(result, 'RELEASE_FAILED'),
|
|
748
|
+
message: consoleErrorMessage(result, `Release endpoint returned ${result.status}`),
|
|
749
|
+
status: result.status,
|
|
750
|
+
url: result.url,
|
|
751
|
+
console_response: result.body,
|
|
752
|
+
attempted: { spec_id: releaseSpecId },
|
|
753
|
+
};
|
|
754
|
+
return {
|
|
755
|
+
isError: true,
|
|
756
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
// The console released the claim. Now (and ONLY now) mark the session
|
|
760
|
+
// worktree eligible for reaping — best-effort, never fatal ([[HS-QDQA]]).
|
|
761
|
+
const worktree = await markWorktreeReleased(releaseSpecId, deps);
|
|
762
|
+
return {
|
|
763
|
+
content: [{
|
|
764
|
+
type: 'text',
|
|
765
|
+
text: JSON.stringify(
|
|
766
|
+
{ spec_id: releaseSpecId, released: true, console_response: result.body, worktree },
|
|
767
|
+
null,
|
|
768
|
+
2,
|
|
769
|
+
),
|
|
770
|
+
}],
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Record the machine-readable WIP ledger for an epic at session close
|
|
776
|
+
* ([[HS-EPIC-FCSM]] forensic-reconstruction incident). Thin forwarder to
|
|
777
|
+
* console `POST /api/specs/:id/wip`, which captures the touched/untracked file
|
|
778
|
+
* lists from `git status` server-side (code-first — not narrated) and merges
|
|
779
|
+
* the agent-supplied semantic fields. The on-deck brief surfaces the entry so
|
|
780
|
+
* the next agent reads state instead of reconstructing it.
|
|
781
|
+
*/
|
|
782
|
+
/** Every argument heyspex_record_wip accepts. Anything else is refused by name. */
|
|
783
|
+
export const RECORD_WIP_ACCEPTED_ARGS = [
|
|
784
|
+
'spec_id',
|
|
785
|
+
'specs_filed',
|
|
786
|
+
'remaining',
|
|
787
|
+
'verified',
|
|
788
|
+
'notes',
|
|
789
|
+
'agent',
|
|
790
|
+
'session_id',
|
|
791
|
+
];
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Arguments an agent plausibly reaches for that this tool has never had, and
|
|
795
|
+
* where the content actually belongs. Named in the refusal so the error tells
|
|
796
|
+
* the caller what to do instead of just saying no.
|
|
797
|
+
*/
|
|
798
|
+
export const RECORD_WIP_ARG_REDIRECTS = {
|
|
799
|
+
summary: 'this tool has no "summary" field — put the hand-off prose in "notes"',
|
|
800
|
+
handoff: 'no such field — put the hand-off prose in "notes"',
|
|
801
|
+
note: 'did you mean "notes"?',
|
|
802
|
+
current_stage: 'belongs to heyspex_report_progress (currentStage), not the WIP ledger',
|
|
803
|
+
currentStage: 'belongs to heyspex_report_progress (currentStage), not the WIP ledger',
|
|
804
|
+
ac_progress: 'belongs to heyspex_report_progress (acProgress), not the WIP ledger',
|
|
805
|
+
acProgress: 'belongs to heyspex_report_progress (acProgress), not the WIP ledger',
|
|
806
|
+
files_touched: 'captured server-side from git status — do not pass it',
|
|
807
|
+
untracked_artifacts: 'captured server-side from git status — do not pass it',
|
|
808
|
+
filesTouched: 'captured server-side from git status — do not pass it',
|
|
809
|
+
untrackedArtifacts: 'captured server-side from git status — do not pass it',
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Mirror of `MAX_NOTES_CHARS` in `spex-console/src/lib/wip-state.ts` — refused
|
|
814
|
+
* here so an over-long hand-off fails before the round trip, with the same
|
|
815
|
+
* number in the message. Keep the two in sync.
|
|
816
|
+
*/
|
|
817
|
+
export const RECORD_WIP_MAX_NOTES_CHARS = 4000;
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Pure verdict on a `heyspex_record_wip` argument bag: either the console body
|
|
821
|
+
* to POST, or the refusal to hand back ([[HS-IRX0-5KFT-DIAG]] AC2).
|
|
822
|
+
*
|
|
823
|
+
* Store it or refuse it. The old code built the body from a fixed whitelist, so
|
|
824
|
+
* an argument the store does not keep (four hand-offs were written into
|
|
825
|
+
* `summary`) vanished with no signal while the call returned success. Anything
|
|
826
|
+
* unlisted, or listed with the wrong type, is now a named refusal — no partial
|
|
827
|
+
* write, no silent drop.
|
|
828
|
+
*
|
|
829
|
+
* Identity (agent / sessionId) is stamped by the caller, so this stays pure.
|
|
830
|
+
*/
|
|
831
|
+
export function recordWipArgsVerdict(args = {}) {
|
|
832
|
+
if (!args.spec_id || typeof args.spec_id !== 'string') {
|
|
833
|
+
return { ok: false, error: { error: 'INVALID_ARGS', message: 'spec_id is required' } };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const unknown = Object.keys(args).filter((k) => !RECORD_WIP_ACCEPTED_ARGS.includes(k));
|
|
837
|
+
if (unknown.length > 0) {
|
|
838
|
+
const detail = unknown
|
|
839
|
+
.map((k) => (RECORD_WIP_ARG_REDIRECTS[k] ? `${k} (${RECORD_WIP_ARG_REDIRECTS[k]})` : k))
|
|
840
|
+
.join(', ');
|
|
841
|
+
return {
|
|
842
|
+
ok: false,
|
|
843
|
+
error: {
|
|
844
|
+
error: 'UNKNOWN_FIELD',
|
|
845
|
+
message: `heyspex_record_wip cannot store: ${detail}. NOTHING WAS WRITTEN — re-send using only the accepted arguments.`,
|
|
846
|
+
field: unknown[0],
|
|
847
|
+
unknown_fields: unknown,
|
|
848
|
+
accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
|
|
849
|
+
attempted: { spec_id: args.spec_id },
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const body = {};
|
|
855
|
+
const listArgs = { specs_filed: 'specsFiled', remaining: 'remaining', verified: 'verified' };
|
|
856
|
+
for (const [argName, bodyName] of Object.entries(listArgs)) {
|
|
857
|
+
if (args[argName] === undefined) continue;
|
|
858
|
+
if (!Array.isArray(args[argName]) || args[argName].some((v) => typeof v !== 'string')) {
|
|
859
|
+
return {
|
|
860
|
+
ok: false,
|
|
861
|
+
error: {
|
|
862
|
+
error: 'INVALID_FIELD',
|
|
863
|
+
message: `"${argName}" must be an array of strings. NOTHING WAS WRITTEN.`,
|
|
864
|
+
field: argName,
|
|
865
|
+
accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
body[bodyName] = args[argName];
|
|
870
|
+
}
|
|
871
|
+
if (args.notes !== undefined) {
|
|
872
|
+
if (typeof args.notes !== 'string') {
|
|
873
|
+
return {
|
|
874
|
+
ok: false,
|
|
875
|
+
error: {
|
|
876
|
+
error: 'INVALID_FIELD',
|
|
877
|
+
message: '"notes" must be a string. NOTHING WAS WRITTEN.',
|
|
878
|
+
field: 'notes',
|
|
879
|
+
accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
if (args.notes.length > RECORD_WIP_MAX_NOTES_CHARS) {
|
|
884
|
+
return {
|
|
885
|
+
ok: false,
|
|
886
|
+
error: {
|
|
887
|
+
error: 'NOTES_TOO_LONG',
|
|
888
|
+
message: `"notes" is ${args.notes.length} characters, over the ${RECORD_WIP_MAX_NOTES_CHARS} the ledger stores. NOTHING WAS WRITTEN — shorten it, or move the long-form detail into the spec body and leave a pointer here.`,
|
|
889
|
+
field: 'notes',
|
|
890
|
+
accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
body.notes = args.notes;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return { ok: true, body };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Record the WIP hand-off ledger — POST /api/specs/:id/wip. Refuses what it
|
|
902
|
+
* cannot store, and surfaces the console's measured `stored` receipt on success
|
|
903
|
+
* ([[HS-IRX0-5KFT-DIAG]]).
|
|
904
|
+
*/
|
|
905
|
+
export async function handleRecordWip(args = {}) {
|
|
906
|
+
const verdict = recordWipArgsVerdict(args);
|
|
907
|
+
if (!verdict.ok) {
|
|
908
|
+
return {
|
|
909
|
+
isError: true,
|
|
910
|
+
content: [{ type: 'text', text: JSON.stringify(verdict.error, null, 2) }],
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
915
|
+
const body = { ...verdict.body, agent: identity.agent, sessionId: identity.sessionId };
|
|
916
|
+
|
|
917
|
+
const result = await consoleFetch(
|
|
918
|
+
`/api/specs/${encodeURIComponent(args.spec_id)}/wip`,
|
|
919
|
+
{ method: 'POST', body },
|
|
920
|
+
);
|
|
921
|
+
if (!result.ok) {
|
|
922
|
+
const payload = {
|
|
923
|
+
error: consoleErrorCode(result, 'WIP_FAILED'),
|
|
924
|
+
message: consoleErrorMessage(result, `WIP endpoint returned ${result.status}`),
|
|
925
|
+
status: result.status,
|
|
926
|
+
url: result.url,
|
|
927
|
+
console_response: result.body,
|
|
928
|
+
attempted: { spec_id: args.spec_id },
|
|
929
|
+
};
|
|
930
|
+
return {
|
|
931
|
+
isError: true,
|
|
932
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
// The console's `stored` block is MEASURED from a re-read of the ledger — lift
|
|
936
|
+
// it to the top level so the receipt is the first thing the agent sees.
|
|
937
|
+
const stored = result.body && typeof result.body === 'object' ? result.body.stored : undefined;
|
|
938
|
+
const warnings = result.body && typeof result.body === 'object' ? result.body.warnings : undefined;
|
|
939
|
+
return {
|
|
940
|
+
content: [{
|
|
941
|
+
type: 'text',
|
|
942
|
+
text: JSON.stringify({
|
|
943
|
+
spec_id: args.spec_id,
|
|
944
|
+
stored,
|
|
945
|
+
...(Array.isArray(warnings) && warnings.length ? { warnings } : {}),
|
|
946
|
+
console_response: result.body,
|
|
947
|
+
}, null, 2),
|
|
948
|
+
}],
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Delegation defer — POST /api/specs/:id/defer ([[HS-TASK-1U0Z]]).
|
|
954
|
+
* Files split child + handoff envelope; console orchestrates pin / claim release / WIP.
|
|
955
|
+
*/
|
|
956
|
+
export async function handleDeferSpec(args = {}) {
|
|
957
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
958
|
+
let specId = typeof args.spec_id === 'string' && args.spec_id.trim() ? args.spec_id.trim() : null;
|
|
959
|
+
if (!specId) {
|
|
960
|
+
return {
|
|
961
|
+
isError: true,
|
|
962
|
+
content: [{
|
|
963
|
+
type: 'text',
|
|
964
|
+
text: JSON.stringify({
|
|
965
|
+
error: 'INVALID_ARGS',
|
|
966
|
+
message: 'spec_id is required — pass the claimed parent HS-TASK-* id',
|
|
967
|
+
}),
|
|
968
|
+
}],
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
const deferResolved = await resolveSpecIdForMutation(specId);
|
|
972
|
+
if (deferResolved.response) return deferResolved.response;
|
|
973
|
+
specId = deferResolved.specId;
|
|
974
|
+
|
|
975
|
+
const body = {
|
|
976
|
+
reason: args.reason,
|
|
977
|
+
summary_done: args.summary_done,
|
|
978
|
+
remaining: args.remaining,
|
|
979
|
+
success_criteria: args.success_criteria,
|
|
980
|
+
agent: identity.agent,
|
|
981
|
+
session_id: identity.sessionId,
|
|
982
|
+
...(args.parent_status ? { parent_status: args.parent_status } : {}),
|
|
983
|
+
...(args.effort !== undefined ? { effort: args.effort } : {}),
|
|
984
|
+
...(typeof args.child_name === 'string' ? { child_name: args.child_name } : {}),
|
|
985
|
+
...(typeof args.handoff_id === 'string' && args.handoff_id.trim()
|
|
986
|
+
? { handoff_id: args.handoff_id.trim() }
|
|
987
|
+
: {}),
|
|
988
|
+
...(args.pin_on_deck === false ? { pin_on_deck: false } : {}),
|
|
989
|
+
...(args.release_claim === false ? { release_claim: false } : {}),
|
|
990
|
+
...(args.record_wip === false ? { record_wip: false } : {}),
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const result = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/defer`, {
|
|
994
|
+
method: 'POST',
|
|
995
|
+
body,
|
|
996
|
+
});
|
|
997
|
+
if (!result.ok) {
|
|
998
|
+
const payload = {
|
|
999
|
+
error: consoleErrorCode(result, 'DEFER_FAILED'),
|
|
1000
|
+
message: consoleErrorMessage(result, `Defer endpoint returned ${result.status}`),
|
|
1001
|
+
status: result.status,
|
|
1002
|
+
url: result.url,
|
|
1003
|
+
console_response: result.body,
|
|
1004
|
+
attempted: { spec_id: specId },
|
|
1005
|
+
};
|
|
1006
|
+
return {
|
|
1007
|
+
isError: true,
|
|
1008
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
content: [{
|
|
1013
|
+
type: 'text',
|
|
1014
|
+
text: JSON.stringify({ spec_id: specId, ...result.body }, null, 2),
|
|
1015
|
+
}],
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Build the POST /api/control/active-claims/acquire request body from
|
|
1021
|
+
* heyspex_claim_acquire args + the resolved caller identity. Pure and exported
|
|
1022
|
+
* so the field-forwarding contract (in particular wait_if_held) is
|
|
1023
|
+
* unit-testable without a live console — [[HS-SESO]] AC3.
|
|
1024
|
+
*/
|
|
1025
|
+
export function buildClaimAcquireBody(args = {}, identity) {
|
|
1026
|
+
return {
|
|
1027
|
+
spec_id: args.spec_id,
|
|
1028
|
+
agent: identity.agent,
|
|
1029
|
+
session_id: identity.sessionId,
|
|
1030
|
+
...(args.ttl_ms !== undefined ? { ttl_ms: args.ttl_ms } : {}),
|
|
1031
|
+
...(args.wait_if_held !== undefined ? { wait_if_held: args.wait_if_held } : {}),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Atomically acquire a claim — [[HS-EPIC-NCPS]] active_claims /
|
|
1037
|
+
* [[HS-EPIC-CLMW]] `hs claim acquire`. POST /api/control/active-claims/acquire.
|
|
1038
|
+
* A 409 is a legitimate "already claimed" outcome (not a transport error): it is
|
|
1039
|
+
* surfaced as a normal result with acquired:false + the existing claimant.
|
|
1040
|
+
*/
|
|
1041
|
+
export async function handleClaimAcquire(args = {}) {
|
|
1042
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
1043
|
+
const result = await consoleFetch('/api/control/active-claims/acquire', {
|
|
1044
|
+
method: 'POST',
|
|
1045
|
+
body: buildClaimAcquireBody(args, identity),
|
|
1046
|
+
});
|
|
1047
|
+
if (result.ok) return controlOk(result.body);
|
|
1048
|
+
if (result.status === 409) return controlOk(result.body); // already_claimed + existing
|
|
1049
|
+
return controlError(result, 'CLAIM_ACQUIRE_FAILED', { spec_id: args.spec_id });
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Release a claim — [[HS-EPIC-NCPS]] active_claims / [[HS-EPIC-CLMW]]
|
|
1054
|
+
* `hs claim release`. DELETE /api/control/active-claims/:spec_id. A 404 is a
|
|
1055
|
+
* legitimate "no active claim" outcome, surfaced as released:false.
|
|
1056
|
+
*/
|
|
1057
|
+
export async function handleClaimRelease(args = {}) {
|
|
1058
|
+
const specId = typeof args.spec_id === 'string' ? args.spec_id.trim() : '';
|
|
1059
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
1060
|
+
const path =
|
|
1061
|
+
`/api/control/active-claims/${encodeURIComponent(specId)}` +
|
|
1062
|
+
`?agent=${encodeURIComponent(identity.agent)}`;
|
|
1063
|
+
const result = await consoleFetch(path, { method: 'DELETE' });
|
|
1064
|
+
if (result.ok) return controlOk(result.body);
|
|
1065
|
+
if (result.status === 404) return controlOk(result.body); // released:false + not_found
|
|
1066
|
+
return controlError(result, 'CLAIM_RELEASE_FAILED', { spec_id: args.spec_id });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* List all active claims — [[HS-EPIC-NCPS]] active_claims / [[HS-EPIC-CLMW]]
|
|
1071
|
+
* `hs claim list`. GET /api/control/active-claims → { claims: [] }.
|
|
1072
|
+
*/
|
|
1073
|
+
export async function handleClaimList(_args = {}) {
|
|
1074
|
+
const result = await consoleFetch('/api/control/active-claims');
|
|
1075
|
+
if (!result.ok) return controlError(result, 'CLAIM_LIST_FAILED');
|
|
1076
|
+
return controlOk(result.body);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* [[HS-Y00G-EPIC]] — Claim a file path for exclusive editing.
|
|
1081
|
+
* POST /api/file-claims/acquire. Default TTL 5 minutes.
|
|
1082
|
+
*/
|
|
1083
|
+
export async function handleClaimFile(args = {}) {
|
|
1084
|
+
if (!args.file_path || typeof args.file_path !== 'string') {
|
|
1085
|
+
return {
|
|
1086
|
+
isError: true,
|
|
1087
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'file_path is required' }) }],
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const identity = detectAgentIdentity(args.agent, args.session_id);
|
|
1091
|
+
const body = {
|
|
1092
|
+
file_path: args.file_path,
|
|
1093
|
+
agent: identity.agent,
|
|
1094
|
+
session_id: identity.sessionId,
|
|
1095
|
+
};
|
|
1096
|
+
if (args.ttl_ms !== undefined) body.ttl_ms = args.ttl_ms;
|
|
1097
|
+
if (args.working_spec_id && typeof args.working_spec_id === 'string') {
|
|
1098
|
+
body.working_spec_id = args.working_spec_id;
|
|
1099
|
+
}
|
|
1100
|
+
const result = await consoleFetch('/api/file-claims/acquire', { method: 'POST', body });
|
|
1101
|
+
if (result.ok) return controlOk(result.body);
|
|
1102
|
+
if (result.status === 409) return controlOk(result.body); // already_claimed + existing
|
|
1103
|
+
return controlError(result, 'FILE_CLAIM_FAILED', { file_path: args.file_path });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* [[HS-Y00G-EPIC]] — Release a file claim.
|
|
1108
|
+
* DELETE /api/file-claims/release.
|
|
1109
|
+
*/
|
|
1110
|
+
export async function handleReleaseFileClaim(args = {}) {
|
|
1111
|
+
if (!args.file_path || typeof args.file_path !== 'string') {
|
|
1112
|
+
return {
|
|
1113
|
+
isError: true,
|
|
1114
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'file_path is required' }) }],
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const result = await consoleFetch('/api/file-claims/release', {
|
|
1118
|
+
method: 'DELETE',
|
|
1119
|
+
body: { file_path: args.file_path },
|
|
1120
|
+
});
|
|
1121
|
+
if (result.ok) return controlOk(result.body);
|
|
1122
|
+
if (result.status === 404) return controlOk(result.body); // released:false + not_found
|
|
1123
|
+
return controlError(result, 'FILE_CLAIM_RELEASE_FAILED', { file_path: args.file_path });
|
|
1124
|
+
}
|