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,1219 @@
|
|
|
1
|
+
// The capture tools (heyspex_active_capture, update_capture, pin_capture,
|
|
2
|
+
// promote_capture, promote_capture_to_diagnose) and the capture-session helpers
|
|
3
|
+
// the prompts, recipes and filing tools share. Extracted from cli.mjs under
|
|
4
|
+
// [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
|
|
5
|
+
//
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// Console capture sessions API helpers — [[HS-TASK-MJJK]]
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
import { parseSpecId, shortLookupIdOf } from './spec-id-lib.mjs';
|
|
11
|
+
import { readViaSelector, resolveReadTier, resolveVaultWriteMode } from './read-path.mjs';
|
|
12
|
+
import { consoleFetch } from './console-transport.mjs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { findSpecFile, loadSpec, parseFrontmatter } from './vault-io.mjs';
|
|
16
|
+
import { collectOccupiedIdsFromSpecs } from '../../scripts/vault-id-lib.mjs';
|
|
17
|
+
import { RECENT_PROMOTION_LINEAGE_MS, computeCaptureSufficiency, extractCaptureCoreFromBody, extractCaptureNotesFromBody, findCaptureSpecFile, loadCaptureFocusOffline, resolveActiveCapture, updateCaptureArgsVerdict, writeCaptureFocus } from './capture-focus.mjs';
|
|
18
|
+
import { consoleErrorCode } from './console-fetch.mjs';
|
|
19
|
+
import { executePromoteCapture, executePromoteCaptureToDiagnose } from './promote-capture.mjs';
|
|
20
|
+
import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
|
|
21
|
+
import { makeCaptureConsoleClient, respondToCaptureWriteFailure } from './capture-console-client.mjs';
|
|
22
|
+
import { buildCaptureBufferPatches } from './capture-buffer-patches.mjs';
|
|
23
|
+
import { resolveActiveCaptureContent } from './capture-active-rehydrate.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Query for pending capture sessions owned by the current user.
|
|
27
|
+
* GET /api/capture/sessions?status=pending
|
|
28
|
+
*/
|
|
29
|
+
export async function listPendingCaptureSessions() {
|
|
30
|
+
const result = await consoleFetch('/api/capture/sessions?status=pending');
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
return { ok: false, error: consoleErrorCode(result, 'LIST_FAILED'), result };
|
|
33
|
+
}
|
|
34
|
+
const sessions = result.body?.sessions || [];
|
|
35
|
+
return { ok: true, sessions, result };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Query for MATERIALIZED capture sessions owned by the current user, most
|
|
40
|
+
* recently active first (server-sorted — listCaptureSessions orders by
|
|
41
|
+
* lastActivityAt desc). Used only for the HS-KFB2 lineage check below — a
|
|
42
|
+
* `capture_sessions` row carries no promotion state of its own (that lives in
|
|
43
|
+
* the materialized FILE's `promoted_to:` frontmatter), so this is the list to
|
|
44
|
+
* scan for "was the idea we're about to fork FROM recently promoted".
|
|
45
|
+
* GET /api/capture/sessions?status=materialized
|
|
46
|
+
*/
|
|
47
|
+
export async function listMaterializedCaptureSessions() {
|
|
48
|
+
const result = await consoleFetch('/api/capture/sessions?status=materialized');
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
return { ok: false, error: consoleErrorCode(result, 'LIST_FAILED'), result };
|
|
51
|
+
}
|
|
52
|
+
const sessions = result.body?.sessions || [];
|
|
53
|
+
return { ok: true, sessions, result };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* [[HS-KFB2-6RGF-DIAG-capture-continuation-mints-a-new-orphan]]: a same-session
|
|
58
|
+
* follow-on to an idea that was JUST promoted should surface as a continuation
|
|
59
|
+
* instead of silently forking a disconnected orphan capture. Given the
|
|
60
|
+
* caller's already-fetched materialized sessions (most-recent-first — the
|
|
61
|
+
* server sorts by lastActivityAt), scans them (bounded, first `limit`) for the
|
|
62
|
+
* first whose on-disk file shows a set `promoted_to:` updated within
|
|
63
|
+
* RECENT_PROMOTION_LINEAGE_MS, and returns that lineage or null.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately advisory-only: never blocks, never attaches automatically —
|
|
66
|
+
* the caller surfaces `continuation_of` to the agent, which SUGGESTS the
|
|
67
|
+
* follow-on belongs on that lineage and gets the user's yes before acting
|
|
68
|
+
* ([[HS-5HYS]] elicitation), exactly like the capture-focus.mjs file-path
|
|
69
|
+
* fix this mirrors. Pure aside from the injected `loadSpecFn` — unit-testable
|
|
70
|
+
* without a live console.
|
|
71
|
+
*
|
|
72
|
+
* @param {Array<{specPath?: string|null}>} materializedSessions
|
|
73
|
+
* @param {(specPath: string) => {frontmatter?: Record<string, unknown>}|null} loadSpecFn
|
|
74
|
+
* @param {number} [limit] how many of the most-recent sessions to check (cheap local file reads; the list is already sorted, so 5 covers any plausible "which of my recent captures did I just promote" window without unbounded I/O).
|
|
75
|
+
* @returns {{ promotedTo: string, retiredCaptureId: string|null, retiredCaptureBasename: string } | null}
|
|
76
|
+
*/
|
|
77
|
+
export function findRecentlyPromotedLineage(materializedSessions, loadSpecFn, limit = 5) {
|
|
78
|
+
if (!Array.isArray(materializedSessions)) return null;
|
|
79
|
+
for (const session of materializedSessions.slice(0, limit)) {
|
|
80
|
+
const specPath = session?.specPath;
|
|
81
|
+
if (typeof specPath !== 'string' || !specPath) continue;
|
|
82
|
+
let spec;
|
|
83
|
+
try {
|
|
84
|
+
spec = loadSpecFn(specPath);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!spec) continue;
|
|
89
|
+
const promotedTo = String(spec.frontmatter?.promoted_to ?? '').trim();
|
|
90
|
+
if (!promotedTo || promotedTo === 'null') continue;
|
|
91
|
+
const updatedAt = Date.parse(String(spec.frontmatter?.updated ?? ''));
|
|
92
|
+
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt >= RECENT_PROMOTION_LINEAGE_MS) continue;
|
|
93
|
+
return {
|
|
94
|
+
promotedTo,
|
|
95
|
+
retiredCaptureId: spec.frontmatter?.id ?? null,
|
|
96
|
+
retiredCaptureBasename: specPath.replace(/^.*[\\/]/, '').replace(/\.md$/, ''),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a new capture session via POST /api/capture/sessions.
|
|
104
|
+
*/
|
|
105
|
+
export async function createCaptureSessionOnConsole(input) {
|
|
106
|
+
const result = await consoleFetch('/api/capture/sessions', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: input,
|
|
109
|
+
});
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
return { ok: false, error: consoleErrorCode(result, 'CREATE_FAILED'), result };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, session: result.body?.session, result };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Capture-session console client — error classification, PATCH wrapper, and
|
|
118
|
+
* outbox drain live in `capture-console-client.mjs` so they're testable
|
|
119
|
+
* without spawning the MCP stdin reader / capture-channel watcher. See
|
|
120
|
+
* [[HS-EPIC-CPMR]] for the error taxonomy and the multi-beat protocol
|
|
121
|
+
* substrate fix.
|
|
122
|
+
*/
|
|
123
|
+
export const captureConsoleClient = makeCaptureConsoleClient(consoleFetch);
|
|
124
|
+
|
|
125
|
+
export const patchCaptureSessionOnConsole = (sessionId, patchBody) =>
|
|
126
|
+
captureConsoleClient.patchCaptureSession(sessionId, patchBody);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* GET a single capture session by id.
|
|
130
|
+
* GET /api/capture/sessions/:id
|
|
131
|
+
*
|
|
132
|
+
* Id tolerance (owner ruling 2026-08-21, the HS-XV4W promote failure): session
|
|
133
|
+
* rows store the SHORT era-3 id (`HS-<fileKey>`), but humans paste whatever
|
|
134
|
+
* form they last saw — the display id (`HS-XV4W-HV6H`) or the full basename.
|
|
135
|
+
* Try the id as given first (legacy `HS-CAP-*` rows resolve that way), then
|
|
136
|
+
* retry with the collapsed short id, so ANY recognizable form of the
|
|
137
|
+
* filename/id resolves — exactly like the local disk lookup does.
|
|
138
|
+
*/
|
|
139
|
+
export async function getCaptureSessionOnConsole(sessionId) {
|
|
140
|
+
const result = await consoleFetch(`/api/capture/sessions/${encodeURIComponent(sessionId)}`);
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
return { ok: true, session: result.body?.session, result };
|
|
143
|
+
}
|
|
144
|
+
const short = shortLookupIdOf(sessionId);
|
|
145
|
+
if (short && short !== String(sessionId).trim()) {
|
|
146
|
+
const retry = await consoleFetch(`/api/capture/sessions/${encodeURIComponent(short)}`);
|
|
147
|
+
if (retry.ok) {
|
|
148
|
+
return { ok: true, session: retry.body?.session, result: retry };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { ok: false, error: consoleErrorCode(result, 'GET_FAILED'), result };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Respond to a failed capture write ([[HS-K27H-DIAG-0G86]]).
|
|
156
|
+
*
|
|
157
|
+
* Capture is online-only ([[heyspex-is-online-only-no-offline]]); the offline
|
|
158
|
+
* buffer exists so nothing is lost across a GENUINE transport failure (status
|
|
159
|
+
* 0: ECONNREFUSED / DNS / timeout / abort) — and ONLY then. An HTTP 4xx/5xx
|
|
160
|
+
* means the server answered: the write failed and is surfaced verbatim, never
|
|
161
|
+
* stashed, and never described as a connectivity problem. The classification
|
|
162
|
+
* + payload building live in capture-console-client.mjs
|
|
163
|
+
* (`respondToCaptureWriteFailure`) so the stash-vs-surface rule is
|
|
164
|
+
* unit-testable; this alias keeps the call sites readable.
|
|
165
|
+
*/
|
|
166
|
+
export const captureWriteFailure = (vaultRoot, args, failure) =>
|
|
167
|
+
respondToCaptureWriteFailure(vaultRoot, args, failure);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Drain offline-buffered captures into a now-reachable session.
|
|
171
|
+
* Thin wrapper around `captureConsoleClient.drainOutbox` — see that module
|
|
172
|
+
* for the transient-vs-structural classification contract. `currentBuffer`
|
|
173
|
+
* (the session's content_buffer) lets replayed append-shaped entries
|
|
174
|
+
* (userQuotes/chatTurns) merge instead of clobbering.
|
|
175
|
+
*/
|
|
176
|
+
export const drainCaptureOutbox = (vaultRoot, sessionId, identity, currentBuffer = {}) =>
|
|
177
|
+
captureConsoleClient.drainOutbox(vaultRoot, sessionId, identity, currentBuffer);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* /hs-promote-capture — promote 1+ captures into a research doc with AI-
|
|
181
|
+
* assisted outline. Implementation lives in `promote-capture.mjs`; two-phase
|
|
182
|
+
* flow when target_research === "NEW" (review then execute), single-phase for
|
|
183
|
+
* existing target_research. Mirrors handlePromoteResearch's injection shape.
|
|
184
|
+
*
|
|
185
|
+
* Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
|
|
186
|
+
* `tier` is resolved ONCE per call here (`resolveReadTier()`) and threaded
|
|
187
|
+
* into `executePromoteCapture`'s deps — never re-resolved downstream. `overrides`
|
|
188
|
+
* lets a test inject `{ tier, consoleUrl, consoleFetch, resolveReadTier }`
|
|
189
|
+
* directly so it never opens a socket or depends on real dev-mode config;
|
|
190
|
+
* every production call site (tool-dispatch.mjs) passes none, so the real
|
|
191
|
+
* `resolveReadTier()` / `effectiveConsoleUrl()` / `consoleFetch` run unchanged.
|
|
192
|
+
*
|
|
193
|
+
* Cloud-tier WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
|
|
194
|
+
* `writeMode` is resolved here the same way and on the same once-per-call rule
|
|
195
|
+
* — `resolveVaultWriteMode()` is the write-side twin of `resolveReadTier()`.
|
|
196
|
+
* This is the seam that makes the cloud write path reachable at all: the
|
|
197
|
+
* execute phase defaults to `deps.writeMode ?? 'local'`, so without this line
|
|
198
|
+
* a cloud-reliant session would still write markdown to a disk it may not have.
|
|
199
|
+
*/
|
|
200
|
+
export async function handlePromoteCapture(vaultRoot, args = {}, overrides = {}) {
|
|
201
|
+
try {
|
|
202
|
+
const doResolveReadTier = overrides.resolveReadTier ?? resolveReadTier;
|
|
203
|
+
const tier = overrides.tier ?? (await doResolveReadTier());
|
|
204
|
+
const doResolveWriteMode = overrides.resolveVaultWriteMode ?? resolveVaultWriteMode;
|
|
205
|
+
const writeMode = overrides.writeMode ?? (await doResolveWriteMode());
|
|
206
|
+
const consoleUrl = overrides.consoleUrl ?? effectiveConsoleUrl();
|
|
207
|
+
const doConsoleFetch = overrides.consoleFetch ?? consoleFetch;
|
|
208
|
+
const result = await executePromoteCapture(vaultRoot, args, {
|
|
209
|
+
consoleFetch: (path, options) => doConsoleFetch(path, options),
|
|
210
|
+
tier,
|
|
211
|
+
writeMode,
|
|
212
|
+
consoleUrl,
|
|
213
|
+
});
|
|
214
|
+
if (result?.error) {
|
|
215
|
+
return {
|
|
216
|
+
isError: true,
|
|
217
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
222
|
+
};
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return {
|
|
225
|
+
isError: true,
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: 'text',
|
|
229
|
+
text: JSON.stringify(
|
|
230
|
+
{
|
|
231
|
+
error: 'PROMOTE_CAPTURE_EXCEPTION',
|
|
232
|
+
message: err?.message ?? String(err),
|
|
233
|
+
},
|
|
234
|
+
null,
|
|
235
|
+
2,
|
|
236
|
+
),
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* heyspex_promote_capture_to_diagnose — one bug-report capture → one HS-DIAG-*
|
|
245
|
+
* diagnose spec ([[HS-MIL-BFIX]]). Single-phase; writes the diagnose + the
|
|
246
|
+
* capture's promoted_to/informs_diagnose linkage transactionally on disk, or
|
|
247
|
+
* POST-diagnose-then-PUT-capture through the console in cloud write mode.
|
|
248
|
+
*
|
|
249
|
+
* `tier` / `writeMode` follow the same once-per-call rule as
|
|
250
|
+
* {@link handlePromoteCapture} — resolved HERE, threaded down, never
|
|
251
|
+
* re-resolved in a helper. `overrides` keeps tests off the network.
|
|
252
|
+
*/
|
|
253
|
+
export async function handlePromoteCaptureToDiagnose(vaultRoot, args = {}, overrides = {}) {
|
|
254
|
+
try {
|
|
255
|
+
const doResolveReadTier = overrides.resolveReadTier ?? resolveReadTier;
|
|
256
|
+
const tier = overrides.tier ?? (await doResolveReadTier());
|
|
257
|
+
const doResolveWriteMode = overrides.resolveVaultWriteMode ?? resolveVaultWriteMode;
|
|
258
|
+
const writeMode = overrides.writeMode ?? (await doResolveWriteMode());
|
|
259
|
+
const consoleUrl = overrides.consoleUrl ?? effectiveConsoleUrl();
|
|
260
|
+
const doConsoleFetch = overrides.consoleFetch ?? consoleFetch;
|
|
261
|
+
const result = await executePromoteCaptureToDiagnose(vaultRoot, args, {
|
|
262
|
+
consoleFetch: (path, options) => doConsoleFetch(path, options),
|
|
263
|
+
tier,
|
|
264
|
+
writeMode,
|
|
265
|
+
consoleUrl,
|
|
266
|
+
});
|
|
267
|
+
if (result?.error) {
|
|
268
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
269
|
+
}
|
|
270
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return {
|
|
273
|
+
isError: true,
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: JSON.stringify(
|
|
278
|
+
{ error: 'PROMOTE_CAPTURE_TO_DIAGNOSE_EXCEPTION', message: err?.message ?? String(err) },
|
|
279
|
+
null,
|
|
280
|
+
2,
|
|
281
|
+
),
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read capture-on-deck state from the Neon control plane, composed the same way
|
|
290
|
+
* the console's `readCaptureFocusState` does: layer-1 pin (`capture_focus_pin`)
|
|
291
|
+
* ⊕ layer-2 metadata (`capture_focus_selection`) ([[HS-TASK-CFL2]]). Layer-2 is
|
|
292
|
+
* attached only when its `spec_path` names the pinned spec, so a stale selection
|
|
293
|
+
* never paints onto a freshly-pinned spec. Falls back to the offline file read
|
|
294
|
+
* (`loadCaptureFocusOffline`) when the console is unreachable — the offline
|
|
295
|
+
* `/hs-capture` seam — which REFUSES a pin whose own `pinned_at` is older than
|
|
296
|
+
* `CAPTURE_FOCUS_FILE_STALE_MS` rather than serving a rotted answer quietly
|
|
297
|
+
* ([[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]). Returns the same
|
|
298
|
+
* shape `loadCaptureFocus` does (or null when no pin, and null when one was refused
|
|
299
|
+
* — the loud stderr warning is what distinguishes those here).
|
|
300
|
+
*/
|
|
301
|
+
export async function readCaptureFocusVendor(vaultRoot) {
|
|
302
|
+
try {
|
|
303
|
+
const [pinRes, selRes] = await Promise.all([
|
|
304
|
+
consoleFetch('/api/control/capture-focus-pin'),
|
|
305
|
+
consoleFetch('/api/control/capture-focus-selection'),
|
|
306
|
+
]);
|
|
307
|
+
if (pinRes?.ok) {
|
|
308
|
+
const pin = pinRes.body?.pin ?? null;
|
|
309
|
+
if (!pin) return null; // Neon authoritative — no pin means nothing on deck.
|
|
310
|
+
const sel = selRes?.ok ? (selRes.body?.selection ?? null) : null;
|
|
311
|
+
const base = (p) => String(p || '').replace(/^.*[/\\]/, '');
|
|
312
|
+
const selMatches = sel?.specPath ? base(sel.specPath).startsWith(pin.specId) : false;
|
|
313
|
+
const focus = {
|
|
314
|
+
spec_path: selMatches ? sel.specPath : `specs/captures/${pin.specId}.md`,
|
|
315
|
+
pinned_at: pin.pinnedAt,
|
|
316
|
+
};
|
|
317
|
+
if (pin.expiresAt) focus.expires = pin.expiresAt;
|
|
318
|
+
if (selMatches && sel) {
|
|
319
|
+
if (sel.kind) focus.kind = sel.kind;
|
|
320
|
+
if (sel.reason) focus.reason = sel.reason;
|
|
321
|
+
if (sel.claimedBy) focus.claimed_by = sel.claimedBy;
|
|
322
|
+
if (sel.claimSession) focus.claim_session = sel.claimSession;
|
|
323
|
+
}
|
|
324
|
+
if (!focus.kind) focus.kind = 'idea';
|
|
325
|
+
return focus;
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
// Fall through to the offline file seam.
|
|
329
|
+
}
|
|
330
|
+
// Offline seam. The file is NOT the system of record on a console-backed vault,
|
|
331
|
+
// so a pin that has rotted there must be refused rather than served as if it were
|
|
332
|
+
// current — a capture silently attaching to a two-month-old pin is the failure
|
|
333
|
+
// ([[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]). `loadCaptureFocusOffline`
|
|
334
|
+
// warns loudly and hands back null; null here already means "nothing on deck",
|
|
335
|
+
// and callers that need to tell the two apart call it directly for the reason.
|
|
336
|
+
return loadCaptureFocusOffline(vaultRoot).focus;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the capture genuinely on deck, from the LIVE pin.
|
|
341
|
+
*
|
|
342
|
+
* This is the read the capture-stage gate needs and the one the old hook could
|
|
343
|
+
* not do. `readCaptureFocusVendor` asks the console first and only falls back to
|
|
344
|
+
* `.heyspex/capture-focus.yaml` when it cannot be reached, so the answer tracks
|
|
345
|
+
* what `/hs-capture` actually pinned rather than whatever the last offline
|
|
346
|
+
* session left on disk.
|
|
347
|
+
*
|
|
348
|
+
* Returns the resolved capture, or null for every "nothing is open" case — no
|
|
349
|
+
* pin, a pin whose capture was promoted or closed, a pin with no file yet that
|
|
350
|
+
* the console cannot confirm, or any failure at all. Null means allow: this
|
|
351
|
+
* resolver never refuses on its own uncertainty.
|
|
352
|
+
*/
|
|
353
|
+
export async function resolveLiveActiveCapture(vaultRoot) {
|
|
354
|
+
let focus = null;
|
|
355
|
+
try {
|
|
356
|
+
focus = await readCaptureFocusVendor(vaultRoot);
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (!focus?.spec_path) return null;
|
|
361
|
+
|
|
362
|
+
const resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, loadSpec, focus);
|
|
363
|
+
if (resolved && !resolved.error && resolved.spec_id) return resolved;
|
|
364
|
+
|
|
365
|
+
// A capture session that has not materialized to a file yet has no markdown to
|
|
366
|
+
// read, so the disk resolve above returns CAPTURE_NOT_FOUND. Ask the console
|
|
367
|
+
// for the live session row before concluding nothing is open — that pending
|
|
368
|
+
// state is the common case during an active /hs-capture.
|
|
369
|
+
if (resolved?.error !== 'CAPTURE_NOT_FOUND') return null;
|
|
370
|
+
const pinnedId = captureSpecIdFromPath(focus.spec_path);
|
|
371
|
+
if (!pinnedId) return null;
|
|
372
|
+
try {
|
|
373
|
+
const got = await getCaptureSessionOnConsole(pinnedId);
|
|
374
|
+
const session = got.ok ? got.session : null;
|
|
375
|
+
if (!session) return null;
|
|
376
|
+
const status = String(session.status ?? '').trim().toLowerCase();
|
|
377
|
+
if (status && status !== 'pending' && status !== 'open') return null;
|
|
378
|
+
return { spec_id: session.id ?? pinnedId, spec_path: session.specPath ?? focus.spec_path, session };
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Build the caller-facing refusal for a capture pin that did not land, so all six
|
|
386
|
+
* `writeCaptureFocusVendor` call sites report the same measured failure instead of
|
|
387
|
+
* each inventing a "pinned" message from its own request.
|
|
388
|
+
*/
|
|
389
|
+
export function captureFocusPinRefusal(pin, { specId, specPath } = {}) {
|
|
390
|
+
return {
|
|
391
|
+
error: 'CAPTURE_PIN_NOT_WRITTEN',
|
|
392
|
+
pinned: false,
|
|
393
|
+
spec_id: specId ?? pin?.spec_id ?? null,
|
|
394
|
+
spec_path: specPath ?? pin?.spec_path ?? null,
|
|
395
|
+
pin,
|
|
396
|
+
message:
|
|
397
|
+
`Capture ${specId ?? pin?.spec_id ?? '(unknown)'} was NOT pinned on deck (${pin?.reason ?? 'unknown'}). ` +
|
|
398
|
+
'Nothing readable persisted, so /hs-capture will resolve to whatever was pinned before — treat this ' +
|
|
399
|
+
'capture as NOT on deck. Check heyspex_dev_server_status, then re-pin with heyspex_pin_capture.',
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Markdown form of the same measured verdict, for the `/hs-capture` PROMPT
|
|
405
|
+
* surfaces (prompts.mjs), which return markdown and have no `isError` channel.
|
|
406
|
+
* Empty string when the pin landed, so a healthy prompt is byte-identical to
|
|
407
|
+
* before; a loud banner ABOVE the capture body when it did not.
|
|
408
|
+
*/
|
|
409
|
+
export function capturePinWarningBanner(pin, specId) {
|
|
410
|
+
if (pin?.pinned) return '';
|
|
411
|
+
return (
|
|
412
|
+
`> ⚠️ **Capture-on-deck pin NOT written** (${pin?.reason ?? 'unknown'}). This capture ` +
|
|
413
|
+
`(\`${specId}\`) is open, but it is **not on deck**: a later \`/hs-capture\` with no id will ` +
|
|
414
|
+
`resolve to a DIFFERENT capture, and \`heyspex_active_capture\` without \`spec_id\` may attach ` +
|
|
415
|
+
`to the wrong one. Always pass \`spec_id: "${specId}"\` explicitly for the rest of this capture, ` +
|
|
416
|
+
`and re-pin with \`heyspex_pin_capture\` once the console is healthy.\n\n`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Pin capture-on-deck + layer-2 metadata to the Neon control plane the same way
|
|
422
|
+
* the console's `setCaptureFocus` does: PUT `capture_focus_pin` (layer-1) +
|
|
423
|
+
* `capture_focus_selection` (layer-2) ([[HS-TASK-CFL2]]).
|
|
424
|
+
*
|
|
425
|
+
* REPORTS THE RESULT, NEVER THE REQUEST ([[HS-IOJW-5YDH-DIAG]] sweep). This used
|
|
426
|
+
* to return a bare boolean that all six callers discarded while printing
|
|
427
|
+
* "pinned"; worse, the `false` branch was not even a uniform failure. There are
|
|
428
|
+
* three genuinely different outcomes and the old boolean collapsed them:
|
|
429
|
+
*
|
|
430
|
+
* neon — both PUTs ok. Pinned, and the read path reads it.
|
|
431
|
+
* file — the console could not be REACHED (status 0). The legacy file
|
|
432
|
+
* write is a real pin here, because `readCaptureFocusVendor`
|
|
433
|
+
* falls through to `loadCaptureFocusOffline` in exactly this
|
|
434
|
+
* case. Genuinely pinned.
|
|
435
|
+
* none — the console ANSWERED and refused the write (non-2xx), or the
|
|
436
|
+
* file write itself failed. The YAML fallback is DEAD in the
|
|
437
|
+
* answered case: `readCaptureFocusVendor` sees `pinRes.ok` and a
|
|
438
|
+
* null pin, and returns null without ever consulting the file.
|
|
439
|
+
* Nothing readable persisted → `pinned: false`.
|
|
440
|
+
*
|
|
441
|
+
* The file write is measured, not assumed (`measureDiskLanding` idiom): the pin
|
|
442
|
+
* is only claimed after the bytes are read back carrying this spec_path.
|
|
443
|
+
*
|
|
444
|
+
* @returns {Promise<{ pinned: boolean, tier: 'neon'|'file'|'none', reason: string,
|
|
445
|
+
* reported_by: 'measured', spec_id: string|null, spec_path: string|null,
|
|
446
|
+
* console_status?: object, landed?: object }>}
|
|
447
|
+
*/
|
|
448
|
+
export async function writeCaptureFocusVendor(
|
|
449
|
+
vaultRoot,
|
|
450
|
+
{ specId, specPath, reason, claim } = {},
|
|
451
|
+
// Injection seam (idiom: fleet-queue-tools.mjs) so a test can drive each of the
|
|
452
|
+
// three outcomes without a console. Production callers pass two args.
|
|
453
|
+
{ fetchImpl = consoleFetch } = {},
|
|
454
|
+
) {
|
|
455
|
+
const id =
|
|
456
|
+
specId ||
|
|
457
|
+
captureSpecIdFromPath(specPath);
|
|
458
|
+
const resolvedPath = specPath || (id ? `specs/captures/${id}.md` : null);
|
|
459
|
+
const base = { reported_by: 'measured', spec_id: id ?? null, spec_path: resolvedPath };
|
|
460
|
+
|
|
461
|
+
// `consoleFetch` never throws — an unreachable console comes back as
|
|
462
|
+
// `{ ok: false, status: 0 }`. So "answered" is `status !== 0`, and that is the
|
|
463
|
+
// distinction that decides whether the file fallback can ever be read back.
|
|
464
|
+
let consoleAnswered = false;
|
|
465
|
+
let consoleStatus = null;
|
|
466
|
+
try {
|
|
467
|
+
if (id) {
|
|
468
|
+
const [pinRes, selRes] = await Promise.all([
|
|
469
|
+
fetchImpl('/api/control/capture-focus-pin', {
|
|
470
|
+
method: 'PUT',
|
|
471
|
+
body: { spec_id: id, pinned_by: claim?.claimed_by || 'agent' },
|
|
472
|
+
}),
|
|
473
|
+
fetchImpl('/api/control/capture-focus-selection', {
|
|
474
|
+
method: 'PUT',
|
|
475
|
+
body: {
|
|
476
|
+
spec_path: resolvedPath,
|
|
477
|
+
kind: 'idea',
|
|
478
|
+
reason: reason ?? null,
|
|
479
|
+
claimed_by: claim?.claimed_by ?? null,
|
|
480
|
+
claim_session: claim?.claim_session ?? null,
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
]);
|
|
484
|
+
if (pinRes?.ok && selRes?.ok) return { ...base, pinned: true, tier: 'neon', reason: 'neon_write_ok' };
|
|
485
|
+
consoleStatus = { pin: pinRes?.status ?? null, selection: selRes?.status ?? null };
|
|
486
|
+
// A non-zero status means the server ANSWERED and rejected the write.
|
|
487
|
+
consoleAnswered = Boolean(pinRes?.status) || Boolean(selRes?.status);
|
|
488
|
+
console.error(
|
|
489
|
+
`[hs-capture] capture focus not pinned to Neon — console returned pin=${pinRes?.status ?? 'no response'} ` +
|
|
490
|
+
`sel=${selRes?.status ?? 'no response'}. Falling back to the offline file.`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error(
|
|
495
|
+
`[hs-capture] capture focus Neon write failed (${err?.message ?? err}); falling back to the offline file.`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Offline / failure fallback: keep the legacy file write (offline capture seam).
|
|
500
|
+
const landed = writeCaptureFocusMeasured(vaultRoot, specPath, reason, claim);
|
|
501
|
+
if (!landed.on_disk || landed.spec_path_readback !== true) {
|
|
502
|
+
return {
|
|
503
|
+
...base,
|
|
504
|
+
pinned: false,
|
|
505
|
+
tier: 'none',
|
|
506
|
+
reason: 'file_write_failed',
|
|
507
|
+
...(consoleStatus ? { console_status: consoleStatus } : {}),
|
|
508
|
+
landed,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (consoleAnswered) {
|
|
512
|
+
// The console is UP and refused the write. Neon is what the read path
|
|
513
|
+
// consults while it is up, so the file just written will never be read back
|
|
514
|
+
// — a shadow, not a pin. Say so rather than reporting a successful fallback.
|
|
515
|
+
return {
|
|
516
|
+
...base,
|
|
517
|
+
pinned: false,
|
|
518
|
+
tier: 'none',
|
|
519
|
+
reason: 'console_answered_but_rejected_write',
|
|
520
|
+
console_status: consoleStatus,
|
|
521
|
+
landed,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return { ...base, pinned: true, tier: 'file', reason: 'console_unreachable_offline_file_pin', landed };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* `writeCaptureFocus` + a read-back, so the offline pin is measured rather than
|
|
529
|
+
* assumed. Mirrors `measureDiskLanding` (tool-helpers.mjs) for a file this module
|
|
530
|
+
* writes through a helper that returns nothing. Never throws.
|
|
531
|
+
*/
|
|
532
|
+
export function writeCaptureFocusMeasured(vaultRoot, specPath, reason, claim) {
|
|
533
|
+
const abs = join(vaultRoot, '.heyspex', 'capture-focus.yaml');
|
|
534
|
+
const out = { tier: 'disk', reported_by: 'measured', abs_path: abs, on_disk: false, bytes: null, spec_path_readback: false };
|
|
535
|
+
try {
|
|
536
|
+
writeCaptureFocus(vaultRoot, specPath, reason, claim);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
out.error = String(err?.message ?? err);
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const raw = readFileSync(abs, 'utf8');
|
|
543
|
+
out.on_disk = true;
|
|
544
|
+
out.bytes = Buffer.byteLength(raw);
|
|
545
|
+
// An empty spec_path is not a pin — `spec_path:` with nothing after it names
|
|
546
|
+
// no capture, so it must never read back as a landed offline pin.
|
|
547
|
+
out.spec_path_readback =
|
|
548
|
+
typeof specPath === 'string' && specPath.trim().length > 0 && raw.includes(`spec_path: ${specPath}`);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
out.error = String(err?.message ?? err);
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Derive the capture spec id (`HS-CAP-XXXX`) from a capture spec_path basename. */
|
|
556
|
+
export function captureSpecIdFromPath(specPath) {
|
|
557
|
+
const baseName = String(specPath || '').replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
|
|
558
|
+
return baseName.match(/^(HS-[A-Z]+-[A-Z0-9]{3,})/)?.[1] || null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Grounding-time sufficiency verdict for the database-backed capture path
|
|
563
|
+
* ([[HS-Q8O5-MG6A-DIAG-the-capture-sufficiency-verdict-never]],
|
|
564
|
+
* [[HS-58O9-X98Y-DIAG-cloud-capture-materializer-never]]): `computeCaptureSufficiency`
|
|
565
|
+
* is the same scorer `heyspex_promote_capture` runs, but it was only ever wired
|
|
566
|
+
* into the disk/file-backed `resolveActiveCapture` path — every database-tier
|
|
567
|
+
* `heyspex_active_capture` / `heyspex_update_capture` response (the normal case
|
|
568
|
+
* whenever the console is reachable) carried no `sufficiency` field at all, so
|
|
569
|
+
* an agent following the /hs-capture protocol had nothing to gate "offer
|
|
570
|
+
* promote" on. Scores the materialized file at `specPath` when one exists;
|
|
571
|
+
* reports `null` with a reason (not a fabricated fail) before materialization,
|
|
572
|
+
* since there is no body yet to score.
|
|
573
|
+
* @param {string} vaultRoot
|
|
574
|
+
* @param {string|null|undefined} specPath
|
|
575
|
+
* @returns {{ sufficiency: object|null, sufficiency_reason?: string }}
|
|
576
|
+
*/
|
|
577
|
+
function sufficiencyForSpecPath(vaultRoot, specPath) {
|
|
578
|
+
if (!specPath) {
|
|
579
|
+
return { sufficiency: null, sufficiency_reason: 'not_yet_materialized' };
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
583
|
+
if (!spec) return { sufficiency: null, sufficiency_reason: 'spec_unreadable' };
|
|
584
|
+
return { sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body) };
|
|
585
|
+
} catch {
|
|
586
|
+
return { sufficiency: null, sufficiency_reason: 'scorer_error' };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* [[HS-TASK-MJJK]] — rewired to query sessions API first.
|
|
592
|
+
* 1. If spec_id is provided, resolve that exact capture DB-first
|
|
593
|
+
* (getCaptureSessionOnConsole), so a pending session resolves; fall back to
|
|
594
|
+
* disk for materialized / legacy captures whose live row is gone.
|
|
595
|
+
* 2. Otherwise, query GET /api/capture/sessions?status=pending for active session.
|
|
596
|
+
* 3. If a pending session exists, return its data (database is source of truth).
|
|
597
|
+
* 4. If API unreachable or no pending session, fallback to file-based logic.
|
|
598
|
+
*/
|
|
599
|
+
export async function handleActiveCapture(vaultRoot, args) {
|
|
600
|
+
// If spec_id is provided, resolve THAT exact capture. The channel push carries
|
|
601
|
+
// the authoritative capture_id (capture-channel.mjs), so the agent is told to
|
|
602
|
+
// pass it here — resolving by id is how we avoid the "wrong capture" divergence.
|
|
603
|
+
if (args.spec_id) {
|
|
604
|
+
const wantedId = String(args.spec_id).trim();
|
|
605
|
+
// DB FIRST: a pending capture session has no markdown file yet, so the
|
|
606
|
+
// disk-only lookup below would 404 it. Resolve the live session row by id so
|
|
607
|
+
// a pending capture (the common channel-driven case) returns real state.
|
|
608
|
+
const got = await getCaptureSessionOnConsole(wantedId);
|
|
609
|
+
if (got.ok && got.session) {
|
|
610
|
+
const session = got.session;
|
|
611
|
+
// Same [[HS-U29N-DIAG-T2DR]] rehydration as the no-spec_id path below —
|
|
612
|
+
// an explicitly-named session can carry the same stale/empty buffer
|
|
613
|
+
// divergence from its materialized file.
|
|
614
|
+
const rehydrated = resolveActiveCaptureContent(session, {
|
|
615
|
+
normalizeBuffer: normalizeCaptureBuffer,
|
|
616
|
+
loadSpec: (specPath) => loadSpec(vaultRoot, specPath),
|
|
617
|
+
extractCore: extractCaptureCoreFromBody,
|
|
618
|
+
extractNotes: extractCaptureNotesFromBody,
|
|
619
|
+
});
|
|
620
|
+
const contentBuffer = rehydrated.contentBuffer;
|
|
621
|
+
const payload = {
|
|
622
|
+
spec_id: session.id,
|
|
623
|
+
spec_path: session.specPath || null,
|
|
624
|
+
name: contentBuffer.name || `Capture ${session.id}`,
|
|
625
|
+
core_idea: contentBuffer.core_idea || '',
|
|
626
|
+
notes: contentBuffer.notes || '',
|
|
627
|
+
status: session.status,
|
|
628
|
+
source: rehydrated.rehydrated_from_disk ? 'database+disk-rehydrated' : 'database',
|
|
629
|
+
is_new: rehydrated.is_new,
|
|
630
|
+
session,
|
|
631
|
+
...sufficiencyForSpecPath(vaultRoot, session.specPath),
|
|
632
|
+
};
|
|
633
|
+
if (!rehydrated.is_new) {
|
|
634
|
+
payload.guidance =
|
|
635
|
+
'This capture already has content (is_new: false) — do NOT overwrite existing sections (core_idea/summary/notes/example/user_stories) without explicit user instruction. Append or ask before replacing.';
|
|
636
|
+
}
|
|
637
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
638
|
+
}
|
|
639
|
+
// DB miss (404 / GET_FAILED) or console unreachable → fall back to disk for
|
|
640
|
+
// materialized / legacy captures whose live session row is gone.
|
|
641
|
+
const specPath =
|
|
642
|
+
findCaptureSpecFile(vaultRoot, wantedId) || findSpecFile(vaultRoot, wantedId);
|
|
643
|
+
if (!specPath) {
|
|
644
|
+
return {
|
|
645
|
+
isError: true,
|
|
646
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: wantedId }) }],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
650
|
+
if (!spec) {
|
|
651
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'LOAD_FAILED' }) }] };
|
|
652
|
+
}
|
|
653
|
+
const diskCore = extractCaptureCoreFromBody(spec.body);
|
|
654
|
+
const diskNotes = extractCaptureNotesFromBody(spec.body);
|
|
655
|
+
const resolved = {
|
|
656
|
+
spec_id: spec.frontmatter?.id || wantedId,
|
|
657
|
+
spec_path: specPath,
|
|
658
|
+
name: spec.frontmatter?.name,
|
|
659
|
+
core_idea: diskCore,
|
|
660
|
+
notes: diskNotes,
|
|
661
|
+
// A disk file exists for this exact id — never a fresh/empty capture,
|
|
662
|
+
// regardless of whether its sections happen to be blank.
|
|
663
|
+
is_new: false,
|
|
664
|
+
focus: await readCaptureFocusVendor(vaultRoot),
|
|
665
|
+
sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body),
|
|
666
|
+
};
|
|
667
|
+
return { content: [{ type: 'text', text: JSON.stringify(resolved, null, 2) }] };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Capture is online-only — query the sessions API. [[HS-TASK-MJJK]]
|
|
671
|
+
const identity = detectAgentIdentity();
|
|
672
|
+
const forceNew = args.force_new === true;
|
|
673
|
+
|
|
674
|
+
// force_new: true ([[HS-1C4L-9VZV-DIAG-multi-agent-capture-focus-pin-collision]])
|
|
675
|
+
// — skip the pending-session list/attach path entirely and unconditionally mint
|
|
676
|
+
// a fresh session. `forceNew: true` also disables the console's empty-session
|
|
677
|
+
// reuse-fold (scoped by ownerUserId+openedBy, NOT openedBySession — so without
|
|
678
|
+
// this, two concurrent agent sessions could still silently fold onto the same
|
|
679
|
+
// empty pending row even when this caller never intended to attach to anything).
|
|
680
|
+
let session = null;
|
|
681
|
+
// HS-KFB2: true only when THIS call minted a brand-new session (never when
|
|
682
|
+
// attaching to an existing pending one) — that's the exact moment a
|
|
683
|
+
// same-session follow-on to a recently-promoted idea would otherwise become
|
|
684
|
+
// a silent orphan. force_new explicitly asks for a genuinely new, unlinked
|
|
685
|
+
// topic (its own doc comment above), so it deliberately skips this check.
|
|
686
|
+
let freshlyCreated = false;
|
|
687
|
+
if (forceNew) {
|
|
688
|
+
const createResult = await createCaptureSessionOnConsole({
|
|
689
|
+
opened_by: 'agent',
|
|
690
|
+
opened_by_session: identity.sessionId,
|
|
691
|
+
mode: 'attended',
|
|
692
|
+
status: 'pending',
|
|
693
|
+
force_new: true,
|
|
694
|
+
});
|
|
695
|
+
if (!createResult.ok) {
|
|
696
|
+
const refusal = captureWriteFailure(vaultRoot, {}, createResult);
|
|
697
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
698
|
+
}
|
|
699
|
+
session = createResult.session;
|
|
700
|
+
} else {
|
|
701
|
+
const sessionsResult = await listPendingCaptureSessions();
|
|
702
|
+
|
|
703
|
+
if (!sessionsResult.ok) {
|
|
704
|
+
// Transport failure → graceful refuse; never mint a local file scaffold
|
|
705
|
+
// ([[heyspex-is-online-only-no-offline]]). active_capture carries no user
|
|
706
|
+
// words, so nothing is stashed. An answered HTTP error is surfaced
|
|
707
|
+
// honestly instead of masquerading as unreachable (HS-K27H).
|
|
708
|
+
const refusal = captureWriteFailure(vaultRoot, {}, sessionsResult);
|
|
709
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Reachable: attach to THE pending session, or agent-first auto-create one — in
|
|
713
|
+
// the DB, not a file. This is where the three surfaces converge.
|
|
714
|
+
session = sessionsResult.sessions[0] || null;
|
|
715
|
+
if (!session) {
|
|
716
|
+
const createResult = await createCaptureSessionOnConsole({
|
|
717
|
+
opened_by: 'agent',
|
|
718
|
+
opened_by_session: identity.sessionId,
|
|
719
|
+
mode: 'attended',
|
|
720
|
+
status: 'pending',
|
|
721
|
+
});
|
|
722
|
+
if (!createResult.ok) {
|
|
723
|
+
const refusal = captureWriteFailure(vaultRoot, {}, createResult);
|
|
724
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
725
|
+
}
|
|
726
|
+
session = createResult.session;
|
|
727
|
+
freshlyCreated = true;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Now that we're online, flush any offline-buffered captures into this session
|
|
732
|
+
// (append against the session's live buffer, not a blank one — HS-K27H).
|
|
733
|
+
const drain = await drainCaptureOutbox(
|
|
734
|
+
vaultRoot,
|
|
735
|
+
session.id,
|
|
736
|
+
identity,
|
|
737
|
+
session.contentBuffer && typeof session.contentBuffer === 'object'
|
|
738
|
+
? session.contentBuffer
|
|
739
|
+
: {},
|
|
740
|
+
);
|
|
741
|
+
if (drain.drained > 0) {
|
|
742
|
+
const refetched = await getCaptureSessionOnConsole(session.id);
|
|
743
|
+
if (refetched.ok && refetched.session) session = refetched.session;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// [[HS-U29N-DIAG-T2DR]] fix: never trust an empty content_buffer at face
|
|
747
|
+
// value when the attached session's capture is already materialized — the
|
|
748
|
+
// buffer and the on-disk file can diverge, and reporting "empty" here is
|
|
749
|
+
// exactly what let an agent conclude a live capture was safe to overwrite.
|
|
750
|
+
// Rehydrate from disk when needed, and always tell the caller whether this
|
|
751
|
+
// is a genuinely new/empty capture via `is_new`.
|
|
752
|
+
const rehydrated = resolveActiveCaptureContent(session, {
|
|
753
|
+
normalizeBuffer: normalizeCaptureBuffer,
|
|
754
|
+
loadSpec: (specPath) => loadSpec(vaultRoot, specPath),
|
|
755
|
+
extractCore: extractCaptureCoreFromBody,
|
|
756
|
+
extractNotes: extractCaptureNotesFromBody,
|
|
757
|
+
});
|
|
758
|
+
const contentBuffer = rehydrated.contentBuffer;
|
|
759
|
+
const payload = {
|
|
760
|
+
spec_id: session.id,
|
|
761
|
+
spec_path: session.specPath || null,
|
|
762
|
+
name: contentBuffer.name || `Capture ${session.id}`,
|
|
763
|
+
core_idea: contentBuffer.core_idea || '',
|
|
764
|
+
notes: contentBuffer.notes || '',
|
|
765
|
+
status: session.status,
|
|
766
|
+
source: rehydrated.rehydrated_from_disk ? 'database+disk-rehydrated' : 'database',
|
|
767
|
+
is_new: rehydrated.is_new,
|
|
768
|
+
session,
|
|
769
|
+
synced_from_offline_buffer: drain.drained || undefined,
|
|
770
|
+
...sufficiencyForSpecPath(vaultRoot, session.specPath),
|
|
771
|
+
};
|
|
772
|
+
if (!rehydrated.is_new) {
|
|
773
|
+
payload.guidance =
|
|
774
|
+
'This capture already has content (is_new: false) — do NOT overwrite existing sections (core_idea/summary/notes/example/user_stories) without explicit user instruction. Append or ask before replacing.';
|
|
775
|
+
}
|
|
776
|
+
// HS-KFB2: this call minted a brand-new session — check whether it's really
|
|
777
|
+
// a same-session follow-on to an idea that was JUST promoted, so the agent
|
|
778
|
+
// can suggest attaching to that lineage instead of treating this as
|
|
779
|
+
// unrelated. Advisory only (never blocks, never auto-attaches); a lookup
|
|
780
|
+
// failure degrades to silence, same as every other best-effort check here.
|
|
781
|
+
if (freshlyCreated) {
|
|
782
|
+
try {
|
|
783
|
+
const materialized = await listMaterializedCaptureSessions();
|
|
784
|
+
if (materialized.ok) {
|
|
785
|
+
const lineage = findRecentlyPromotedLineage(materialized.sessions, (specPath) =>
|
|
786
|
+
loadSpec(vaultRoot, specPath),
|
|
787
|
+
);
|
|
788
|
+
if (lineage) {
|
|
789
|
+
payload.continuation_of = lineage.promotedTo;
|
|
790
|
+
payload.continuation_note =
|
|
791
|
+
`This capture was just auto-created, and a recently-promoted idea (${lineage.promotedTo}` +
|
|
792
|
+
(lineage.retiredCaptureId ? `, from ${lineage.retiredCaptureId}` : '') +
|
|
793
|
+
`) is still fresh in this session. Before treating this as unrelated, consider telling the ` +
|
|
794
|
+
`user this looks like a follow-on to that idea and offering to add it to ${lineage.promotedTo} ` +
|
|
795
|
+
`instead of (or in addition to) this new capture ([[HS-KFB2-6RGF-DIAG-capture-continuation-mints-a-new-orphan]]).`;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
// best-effort — never block active_capture on this check
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* [[HS-TASK-MJJK]] + Defect 3 — capture is online-only ([[heyspex-is-online-only-no-offline]]).
|
|
807
|
+
* 1. If spec_id is provided, use that session.
|
|
808
|
+
* 2. Otherwise, attach to the pending session, or agent-first auto-create one in the DB.
|
|
809
|
+
* 3. Drain any offline-buffered captures, then PATCH the session's content_buffer.
|
|
810
|
+
* 4. If console is unreachable, stash the verbatim input to the offline buffer
|
|
811
|
+
* ([[capture-outbox]]) and refuse gracefully — NEVER a divergent file scaffold.
|
|
812
|
+
*
|
|
813
|
+
* Materialization is triggered server-side per [[HS-TASK-U9E7]].
|
|
814
|
+
*/
|
|
815
|
+
export async function handleUpdateCapture(vaultRoot, args) {
|
|
816
|
+
// First hop: refuse an unrecognized/misspelled arg BY NAME instead of
|
|
817
|
+
// letting it silently vanish into buildCaptureBufferPatches's explicit
|
|
818
|
+
// allowlist — the declared `additionalProperties: false` inputSchema above
|
|
819
|
+
// is never validated at runtime by any layer
|
|
820
|
+
// [[additionalproperties-false-in-an-mcp]].
|
|
821
|
+
const argsVerdict = updateCaptureArgsVerdict(args);
|
|
822
|
+
if (!argsVerdict.ok) {
|
|
823
|
+
return { content: [{ type: 'text', text: JSON.stringify(argsVerdict.error, null, 2) }] };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const identity = detectAgentIdentity();
|
|
827
|
+
|
|
828
|
+
// Resolve the session to update — online-only, no file-mode fallback.
|
|
829
|
+
let sessionId = args.spec_id?.trim();
|
|
830
|
+
|
|
831
|
+
if (!sessionId) {
|
|
832
|
+
const sessionsResult = await listPendingCaptureSessions();
|
|
833
|
+
if (!sessionsResult.ok) {
|
|
834
|
+
// Transport failure → stash the verbatim words + graceful refuse (no
|
|
835
|
+
// file write). An answered HTTP error is surfaced honestly instead —
|
|
836
|
+
// never stashed, never called "unreachable" (HS-K27H).
|
|
837
|
+
const refusal = captureWriteFailure(vaultRoot, args, sessionsResult);
|
|
838
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
839
|
+
}
|
|
840
|
+
if (sessionsResult.sessions.length > 0) {
|
|
841
|
+
sessionId = sessionsResult.sessions[0].id;
|
|
842
|
+
} else {
|
|
843
|
+
// Reachable but no pending session → agent-first auto-create in the DB.
|
|
844
|
+
const createResult = await createCaptureSessionOnConsole({
|
|
845
|
+
opened_by: 'agent',
|
|
846
|
+
opened_by_session: identity.sessionId,
|
|
847
|
+
mode: 'attended',
|
|
848
|
+
status: 'pending',
|
|
849
|
+
});
|
|
850
|
+
if (!createResult.ok) {
|
|
851
|
+
const refusal = captureWriteFailure(vaultRoot, args, createResult);
|
|
852
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
853
|
+
}
|
|
854
|
+
sessionId = createResult.session.id;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Read the current buffer BEFORE draining so replayed append-shaped entries
|
|
859
|
+
// (userQuotes/chatTurns) merge with what is already on the session instead
|
|
860
|
+
// of clobbering it (HS-K27H) — and so this call's own multi-beat appends
|
|
861
|
+
// merge too. The console PATCH route stores top-level keys verbatim
|
|
862
|
+
// (last-write-wins); the buffer must carry the canonical STRUCTURED shapes
|
|
863
|
+
// (userQuotes: Array<{at,text}>, coreIdea/concept/details:
|
|
864
|
+
// {at,text,confirmed}); flat strings render an empty body ([[HS-EPIC-CPMR]]).
|
|
865
|
+
let currentBuffer = {};
|
|
866
|
+
const existing = await getCaptureSessionOnConsole(sessionId);
|
|
867
|
+
if (
|
|
868
|
+
existing.ok &&
|
|
869
|
+
existing.session &&
|
|
870
|
+
existing.session.contentBuffer &&
|
|
871
|
+
typeof existing.session.contentBuffer === 'object'
|
|
872
|
+
) {
|
|
873
|
+
currentBuffer = existing.session.contentBuffer;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Online with a session → flush any offline-buffered captures first (oldest
|
|
877
|
+
// first, chronological), then file this update. Re-read the buffer if the
|
|
878
|
+
// drain wrote anything, so this call's patches build on the drained state.
|
|
879
|
+
const drainResult = await drainCaptureOutbox(vaultRoot, sessionId, identity, currentBuffer);
|
|
880
|
+
if (drainResult.drained > 0) {
|
|
881
|
+
const refetched = await getCaptureSessionOnConsole(sessionId);
|
|
882
|
+
if (
|
|
883
|
+
refetched.ok &&
|
|
884
|
+
refetched.session &&
|
|
885
|
+
refetched.session.contentBuffer &&
|
|
886
|
+
typeof refetched.session.contentBuffer === 'object'
|
|
887
|
+
) {
|
|
888
|
+
currentBuffer = refetched.session.contentBuffer;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const iso = new Date().toISOString();
|
|
893
|
+
const patches = buildCaptureBufferPatches(args, currentBuffer, iso);
|
|
894
|
+
|
|
895
|
+
const attach = { agent: identity.agent, sessionId: identity.sessionId };
|
|
896
|
+
const patchResult = await patchCaptureSessionOnConsole(sessionId, { patches, attach });
|
|
897
|
+
|
|
898
|
+
if (!patchResult.ok) {
|
|
899
|
+
// HS-K27H classification — one decision point:
|
|
900
|
+
// transport (status 0) → stash to the offline buffer + honest sync
|
|
901
|
+
// message (the next drain replays every stashed field).
|
|
902
|
+
// 4xx → status "refused": the server answered and rejected the write;
|
|
903
|
+
// server error + message verbatim; NOT stashed (the pre-fix 409
|
|
904
|
+
// dead-letters permanently head-of-line-blocked the queue).
|
|
905
|
+
// 5xx → status "failed": the server answered and errored; message
|
|
906
|
+
// verbatim; NOT stashed (pre-fix this was disguised as
|
|
907
|
+
// "can't reach the server" with a false sync promise).
|
|
908
|
+
const payload = captureWriteFailure(vaultRoot, args, patchResult);
|
|
909
|
+
if (payload.status !== 'deferred') payload.spec_id = sessionId;
|
|
910
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const payload = {
|
|
914
|
+
success: true,
|
|
915
|
+
spec_id: sessionId,
|
|
916
|
+
spec_path: patchResult.session?.specPath || null,
|
|
917
|
+
source: 'database',
|
|
918
|
+
session: patchResult.session,
|
|
919
|
+
materialization: patchResult.materialization || null,
|
|
920
|
+
message: 'Capture session updated via API. Materialization handled server-side.',
|
|
921
|
+
...sufficiencyForSpecPath(vaultRoot, patchResult.session?.specPath),
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// Agent-originated captures never auto-open a browser window (companion /
|
|
925
|
+
// chrome-extension only). On FIRST materialization, surface a one-shot
|
|
926
|
+
// "want to see it?" offer so the agent can ask and open on a plain yes.
|
|
927
|
+
const mat = patchResult.materialization;
|
|
928
|
+
const openedBy = patchResult.session?.openedBy;
|
|
929
|
+
if (mat && mat.materialized === true && mat.first === true && openedBy === 'agent') {
|
|
930
|
+
payload.offer_open_viewer = {
|
|
931
|
+
prompt:
|
|
932
|
+
`Capture \`${sessionId}\` is on disk. Want to see it? Say **yes** / **open** / **show** and I'll open the doc.`,
|
|
933
|
+
confirm_tokens: ['yes', 'open', 'show'],
|
|
934
|
+
on_confirm: {
|
|
935
|
+
shell: `node bin/hs.mjs view docs ${sessionId}`,
|
|
936
|
+
note:
|
|
937
|
+
'Opens the capture in a chromeless solo docs window. NEVER auto-open without an explicit confirm token from the user.',
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* [[HS-TASK-MJJK]], fixed per [[HS-1C4L-9VZV-DIAG-multi-agent-capture-focus-pin-collision]]
|
|
947
|
+
* AC3 — `spec_id` is REQUIRED by the tool's inputSchema, but the original
|
|
948
|
+
* implementation silently ignored it and always created/reused an unrelated
|
|
949
|
+
* session — a caller asking to pin an EXISTING capture got a different one
|
|
950
|
+
* back with no error. That made the tool non-functional for its documented
|
|
951
|
+
* purpose (the "escape hatch" heyspex_active_capture's own description
|
|
952
|
+
* points to), which is what led the diagnose to conclude it had no real
|
|
953
|
+
* callable surface.
|
|
954
|
+
*
|
|
955
|
+
* Now: `spec_id` given → resolve THAT capture (DB session, else on-disk
|
|
956
|
+
* materialized spec) and pin it — touching the DB row's activity/attachment
|
|
957
|
+
* so it becomes the most-recently-active pending session (the "on deck" one
|
|
958
|
+
* for the no-spec_id `heyspex_active_capture` resolution), never creating or
|
|
959
|
+
* attaching to a different capture. `spec_id` omitted → back-compat: mint a
|
|
960
|
+
* fresh session, same as before.
|
|
961
|
+
*/
|
|
962
|
+
export async function handlePinCapture(vaultRoot, args) {
|
|
963
|
+
const identity = detectAgentIdentity();
|
|
964
|
+
const wantedId = args.spec_id ? String(args.spec_id).trim() : '';
|
|
965
|
+
|
|
966
|
+
if (wantedId) {
|
|
967
|
+
const got = await getCaptureSessionOnConsole(wantedId);
|
|
968
|
+
if (got.ok && got.session) {
|
|
969
|
+
// Touch the row (empty patch + attach) so it becomes sessions[0] for
|
|
970
|
+
// subsequent no-spec_id lookups — the actual "pin" effect, without
|
|
971
|
+
// mutating any content. Refuses (409) only if the row is abandoned.
|
|
972
|
+
const patchResult = await patchCaptureSessionOnConsole(wantedId, {
|
|
973
|
+
patches: [],
|
|
974
|
+
attach: { agent: identity.agent, sessionId: identity.sessionId },
|
|
975
|
+
});
|
|
976
|
+
const session = patchResult.ok ? patchResult.session : got.session;
|
|
977
|
+
const pin = await writeCaptureFocusVendor(vaultRoot, {
|
|
978
|
+
specId: session.id,
|
|
979
|
+
specPath: session.specPath || '',
|
|
980
|
+
reason: args.reason || 'Pinned via MCP',
|
|
981
|
+
claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
|
|
982
|
+
});
|
|
983
|
+
// Measured, not assumed: `pinned: false` means nothing readable persisted,
|
|
984
|
+
// so this cannot return `success: true, message: "... pinned ..."`.
|
|
985
|
+
if (!pin.pinned) {
|
|
986
|
+
return {
|
|
987
|
+
isError: true,
|
|
988
|
+
content: [{
|
|
989
|
+
type: 'text',
|
|
990
|
+
text: JSON.stringify({
|
|
991
|
+
...captureFocusPinRefusal(pin, { specId: session.id, specPath: session.specPath || null }),
|
|
992
|
+
source: 'database',
|
|
993
|
+
session,
|
|
994
|
+
}, null, 2),
|
|
995
|
+
}],
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
content: [{
|
|
1000
|
+
type: 'text',
|
|
1001
|
+
text: JSON.stringify({
|
|
1002
|
+
success: true,
|
|
1003
|
+
spec_id: session.id,
|
|
1004
|
+
spec_path: session.specPath || null,
|
|
1005
|
+
source: 'database',
|
|
1006
|
+
session,
|
|
1007
|
+
pin,
|
|
1008
|
+
pinned: true,
|
|
1009
|
+
message: `Capture ${session.id} pinned for /hs-capture (${pin.tier})`,
|
|
1010
|
+
}, null, 2),
|
|
1011
|
+
}],
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
// DB miss → fall back to a materialized on-disk capture (legacy / no live
|
|
1015
|
+
// session row). Pin the file directly; no session to touch.
|
|
1016
|
+
const specPath = findCaptureSpecFile(vaultRoot, wantedId) || findSpecFile(vaultRoot, wantedId);
|
|
1017
|
+
if (!specPath) {
|
|
1018
|
+
return {
|
|
1019
|
+
isError: true,
|
|
1020
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: wantedId }) }],
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const pin = await writeCaptureFocusVendor(vaultRoot, {
|
|
1024
|
+
specId: wantedId,
|
|
1025
|
+
specPath,
|
|
1026
|
+
reason: args.reason || 'Pinned via MCP',
|
|
1027
|
+
claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
|
|
1028
|
+
});
|
|
1029
|
+
if (!pin.pinned) {
|
|
1030
|
+
return {
|
|
1031
|
+
isError: true,
|
|
1032
|
+
content: [{
|
|
1033
|
+
type: 'text',
|
|
1034
|
+
text: JSON.stringify(
|
|
1035
|
+
{ ...captureFocusPinRefusal(pin, { specId: wantedId, specPath }), source: 'disk' },
|
|
1036
|
+
null,
|
|
1037
|
+
2,
|
|
1038
|
+
),
|
|
1039
|
+
}],
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
content: [{
|
|
1044
|
+
type: 'text',
|
|
1045
|
+
text: JSON.stringify({
|
|
1046
|
+
success: true,
|
|
1047
|
+
spec_id: wantedId,
|
|
1048
|
+
spec_path: specPath,
|
|
1049
|
+
source: 'disk',
|
|
1050
|
+
pin,
|
|
1051
|
+
pinned: true,
|
|
1052
|
+
message: `Capture ${wantedId} pinned for /hs-capture (disk-only, no live session; ${pin.tier})`,
|
|
1053
|
+
}, null, 2),
|
|
1054
|
+
}],
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// No spec_id → back-compat: create a session row first (pre-existing
|
|
1059
|
+
// behavior, unchanged).
|
|
1060
|
+
const createResult = await createCaptureSessionOnConsole({
|
|
1061
|
+
opened_by: 'agent',
|
|
1062
|
+
opened_by_session: identity.sessionId,
|
|
1063
|
+
mode: 'attended',
|
|
1064
|
+
status: 'pending',
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (createResult.ok && createResult.session) {
|
|
1068
|
+
// Session created — pin capture-on-deck + layer-2 metadata to Neon
|
|
1069
|
+
// ([[HS-TASK-CFL2]]; file is the offline fallback).
|
|
1070
|
+
const session = createResult.session;
|
|
1071
|
+
const pin = await writeCaptureFocusVendor(vaultRoot, {
|
|
1072
|
+
specId: session.id,
|
|
1073
|
+
specPath: session.specPath || '',
|
|
1074
|
+
reason: args.reason || 'Pinned via MCP',
|
|
1075
|
+
claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
if (!pin.pinned) {
|
|
1079
|
+
// The session row WAS created — say so, so the caller does not mint a second
|
|
1080
|
+
// one — but never report it as pinned when nothing readable persisted.
|
|
1081
|
+
return {
|
|
1082
|
+
isError: true,
|
|
1083
|
+
content: [{
|
|
1084
|
+
type: 'text',
|
|
1085
|
+
text: JSON.stringify({
|
|
1086
|
+
...captureFocusPinRefusal(pin, { specId: session.id, specPath: session.specPath || null }),
|
|
1087
|
+
source: 'database',
|
|
1088
|
+
session_created: true,
|
|
1089
|
+
session,
|
|
1090
|
+
}, null, 2),
|
|
1091
|
+
}],
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return {
|
|
1096
|
+
content: [{
|
|
1097
|
+
type: 'text',
|
|
1098
|
+
text: JSON.stringify({
|
|
1099
|
+
success: true,
|
|
1100
|
+
spec_id: session.id,
|
|
1101
|
+
spec_path: session.specPath || null,
|
|
1102
|
+
source: 'database',
|
|
1103
|
+
session,
|
|
1104
|
+
pin,
|
|
1105
|
+
pinned: true,
|
|
1106
|
+
message: `Capture session ${session.id} created and pinned for /hs-capture (${pin.tier})`,
|
|
1107
|
+
}, null, 2),
|
|
1108
|
+
}],
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Unreachable console → graceful refuse; no local file pin (online-only).
|
|
1113
|
+
const refusal = captureWriteFailure(vaultRoot, args, createResult);
|
|
1114
|
+
return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Accept HS-CAP-* primarily; HS-IDEA-* still recognized for legacy pins until E2.
|
|
1118
|
+
// CAP is family-keyed (dual-read via parseSpecId — accepts HS-CAP-XXXX and
|
|
1119
|
+
// HS-XXXX-CAP); IDEA is not reordered, so it stays a type-first prefix check
|
|
1120
|
+
// ([[HS-EPIC-DLE3]]).
|
|
1121
|
+
export function isCaptureSpecId(id) {
|
|
1122
|
+
const v = String(id ?? '').trim();
|
|
1123
|
+
return parseSpecId(v)?.typeLabel === 'CAP' || /^HS-IDEA-[A-Z0-9]{3,}$/i.test(v);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export function collectOccupiedCaptureIds(vaultRoot) {
|
|
1127
|
+
const scanSpecs = [];
|
|
1128
|
+
for (const [dir, type] of [
|
|
1129
|
+
['captures', 'capture'],
|
|
1130
|
+
['ideas', 'capture'], // legacy bucket; still contributes to occupied set
|
|
1131
|
+
]) {
|
|
1132
|
+
const root = join(vaultRoot, 'specs', dir);
|
|
1133
|
+
if (!existsSync(root)) continue;
|
|
1134
|
+
for (const file of readdirSync(root)) {
|
|
1135
|
+
if (!file.endsWith('.md') || file.endsWith('.review.md')) continue;
|
|
1136
|
+
try {
|
|
1137
|
+
const content = readFileSync(join(root, file), 'utf-8');
|
|
1138
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
1139
|
+
scanSpecs.push({
|
|
1140
|
+
id: String(frontmatter.id ?? ''),
|
|
1141
|
+
basename: file.replace(/\.md$/i, ''),
|
|
1142
|
+
type,
|
|
1143
|
+
});
|
|
1144
|
+
} catch {
|
|
1145
|
+
/* skip */
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return collectOccupiedIdsFromSpecs(scanSpecs).captures;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Cloud-reliant sibling of `collectOccupiedCaptureIds` ([[HS-DJWP-MCPC-TASK]]
|
|
1154
|
+
* "Occupied-id scans" family). `collectOccupiedCaptureIds` stays untouched
|
|
1155
|
+
* (the `diskRead` branch); in `neon` tier this unions `GET
|
|
1156
|
+
* /api/vault/occupied-ids?bucket=captures` and `?bucket=ideas` instead of
|
|
1157
|
+
* `readdirSync`ing `specs/{captures,ideas}`.
|
|
1158
|
+
*
|
|
1159
|
+
* NOTE: as of this task, `collectOccupiedCaptureIds` itself has no caller in
|
|
1160
|
+
* cli.mjs (confirmed by grep — pre-existing dead code, unrelated to this
|
|
1161
|
+
* task). This wrapper is wired and tested so the capability exists the moment
|
|
1162
|
+
* a caller needs it, without leaving a disk-only gap behind.
|
|
1163
|
+
*/
|
|
1164
|
+
export async function collectOccupiedCaptureIdsViaTier(vaultRoot, { tier, consoleUrl }) {
|
|
1165
|
+
return readViaSelector({
|
|
1166
|
+
tier,
|
|
1167
|
+
diskRead: () => collectOccupiedCaptureIds(vaultRoot),
|
|
1168
|
+
neonRead: async () => {
|
|
1169
|
+
const [capturesRes, ideasRes] = await Promise.all([
|
|
1170
|
+
consoleFetch('/api/vault/occupied-ids?bucket=captures'),
|
|
1171
|
+
consoleFetch('/api/vault/occupied-ids?bucket=ideas'),
|
|
1172
|
+
]);
|
|
1173
|
+
if (!capturesRes.ok) {
|
|
1174
|
+
throw new Error(`GET /api/vault/occupied-ids?bucket=captures returned ${capturesRes.status}: ${capturesRes.error || ''}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (!ideasRes.ok) {
|
|
1177
|
+
throw new Error(`GET /api/vault/occupied-ids?bucket=ideas returned ${ideasRes.status}: ${ideasRes.error || ''}`);
|
|
1178
|
+
}
|
|
1179
|
+
return new Set([
|
|
1180
|
+
...(Array.isArray(capturesRes.body?.ids) ? capturesRes.body.ids : []),
|
|
1181
|
+
...(Array.isArray(ideasRes.body?.ids) ? ideasRes.body.ids : []),
|
|
1182
|
+
]);
|
|
1183
|
+
},
|
|
1184
|
+
routeLabel: 'GET /api/vault/occupied-ids',
|
|
1185
|
+
consoleUrl,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* [[HS-PG9I-TASK]] — pull a text value out of a content_buffer field that may be
|
|
1191
|
+
* either a flattened string OR the structured `{ at, text, confirmed }` object the
|
|
1192
|
+
* PATCH route stores ([[capture-buffer-patches]]). The live console GET returns the
|
|
1193
|
+
* STRUCTURED shape (`coreIdea`/`summary`/`userQuotes`), so resume detection must not
|
|
1194
|
+
* assume the flat keys.
|
|
1195
|
+
*/
|
|
1196
|
+
export function bufferText(value) {
|
|
1197
|
+
if (!value) return '';
|
|
1198
|
+
if (typeof value === 'string') return value.trim();
|
|
1199
|
+
if (typeof value === 'object' && value.text) return String(value.text).trim();
|
|
1200
|
+
return '';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* [[HS-PG9I-TASK]] — normalize a session content_buffer into { core, summary, notes }
|
|
1205
|
+
* regardless of whether the console returned flat keys (core_idea/notes) or the
|
|
1206
|
+
* structured shape (coreIdea/summary/userQuotes[]). This is what makes the resume
|
|
1207
|
+
* branch fire for a DB-resident capture that already carries a Core Idea.
|
|
1208
|
+
*/
|
|
1209
|
+
export function normalizeCaptureBuffer(contentBuffer = {}) {
|
|
1210
|
+
const core = bufferText(contentBuffer.core_idea ?? contentBuffer.coreIdea);
|
|
1211
|
+
const summary = bufferText(contentBuffer.summary ?? contentBuffer.concept);
|
|
1212
|
+
let notes = typeof contentBuffer.notes === 'string' ? contentBuffer.notes : '';
|
|
1213
|
+
if (!notes && Array.isArray(contentBuffer.userQuotes)) {
|
|
1214
|
+
notes = contentBuffer.userQuotes
|
|
1215
|
+
.map((q) => `- **${q.at || ''} · you:** "${bufferText(q) || q.text || ''}"`)
|
|
1216
|
+
.join('\n');
|
|
1217
|
+
}
|
|
1218
|
+
return { core, summary, notes };
|
|
1219
|
+
}
|