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
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { mintFileKey, mintFamilyKey, parseSpecId } from './spec-id-lib.mjs';
|
|
12
|
+
import { join, isAbsolute, resolve, sep } from 'node:path';
|
|
13
|
+
import { collectOccupiedKeysEitherKind, mintFileKey, mintFamilyKey, parseSpecId } from './spec-id-lib.mjs';
|
|
14
|
+
import { consoleFetch as realConsoleFetch } from './console-transport.mjs';
|
|
15
|
+
import { getConsoleUrl } from './mcp-config.mjs';
|
|
16
|
+
import { resolveVaultWriteMode } from './read-path.mjs';
|
|
17
|
+
import { cloudWriteFailure, createSpecViaConsole, updateSpecViaConsole } from './cloud-vault-writes.mjs';
|
|
14
18
|
import { yamlScalar } from './yaml-scalar.mjs';
|
|
19
|
+
import { ownerFrontmatterEntry, resolveSessionOwner } from './session-owner.mjs';
|
|
15
20
|
import { scoreSufficiency, SUFFICIENCY_THRESHOLD } from '../../dev-factory/lib/sufficiency-score.mjs';
|
|
21
|
+
import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
22
|
+
|
|
16
23
|
|
|
17
24
|
export const CAPTURE_CORE_SOFT_MAX = 200;
|
|
18
25
|
export const CAPTURE_CORE_HARD_MAX = 500;
|
|
@@ -38,6 +45,68 @@ export const CAPTURE_SUMMARY_CHAR_HARD_MAX = 1500;
|
|
|
38
45
|
*/
|
|
39
46
|
export const CAPTURE_PIN_FUSE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Staleness limit for the OFFLINE pin file, checked against its own `pinned_at`.
|
|
50
|
+
* [[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]].
|
|
51
|
+
*
|
|
52
|
+
* On a console-backed vault the pin lives in Neon and `.heyspex/capture-focus.yaml`
|
|
53
|
+
* is only the fallback for when the console cannot be reached — so the file stops
|
|
54
|
+
* being written and quietly rots. This repo's file held a 25 June pin for over two
|
|
55
|
+
* months while three captures were created and promoted against Neon. The failure
|
|
56
|
+
* is NOT the staleness: it is that a stale read and a genuine "nothing is pinned"
|
|
57
|
+
* answer were the same answer, so an offline capture attached to a two-month-old
|
|
58
|
+
* wrong pin and reported success. `loadCaptureFocusOffline` refuses past this age
|
|
59
|
+
* and says so, instead of returning a wrong answer quietly.
|
|
60
|
+
*
|
|
61
|
+
* WHY 7 DAYS, and not shorter or longer:
|
|
62
|
+
* - It is the number this codebase already ruled correct for "this capture pin is
|
|
63
|
+
* abandoned" — `CAPTURE_PIN_FUSE_MS` above, which mirrors the epic-pin silent-fuse
|
|
64
|
+
* convention. One concept, one number; a second, different threshold for the same
|
|
65
|
+
* judgement would be the thing that drifts.
|
|
66
|
+
* - It is ~28× the 6h `RECENT_PROMOTION_LINEAGE_MS` window that bounds "the same
|
|
67
|
+
* working session". A capture is a fast brain-offload artifact, opened and promoted
|
|
68
|
+
* within a session, so a genuinely live pin is hours old, never days — 7 days clears
|
|
69
|
+
* a real pin by a wide margin and cannot false-positive on a weekend or a holiday.
|
|
70
|
+
* - Shorter (say 24h) would start refusing legitimate free-tier pins, where this file
|
|
71
|
+
* IS the system of record and a pin may legitimately sit over a long weekend.
|
|
72
|
+
* Tunable here; deliberately derived from the fuse so the two cannot diverge.
|
|
73
|
+
*/
|
|
74
|
+
export const CAPTURE_FOCUS_FILE_STALE_MS = CAPTURE_PIN_FUSE_MS;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Age-check a loaded pin against its own `pinned_at`.
|
|
78
|
+
*
|
|
79
|
+
* A pin with a missing or unparsable `pinned_at` counts as STALE: we cannot prove
|
|
80
|
+
* it is current, and "cannot prove" must not read as "is fine" — that equivalence
|
|
81
|
+
* is the bug this whole guard exists to close.
|
|
82
|
+
*
|
|
83
|
+
* @returns {{ stale: boolean, ageMs: number|null, pinnedAt: string|null }}
|
|
84
|
+
*/
|
|
85
|
+
export function captureFocusStaleness(focus, { now = Date.now(), maxAgeMs = CAPTURE_FOCUS_FILE_STALE_MS } = {}) {
|
|
86
|
+
const pinnedAt = typeof focus?.pinned_at === 'string' ? focus.pinned_at.trim() : '';
|
|
87
|
+
if (!pinnedAt) return { stale: true, ageMs: null, pinnedAt: null };
|
|
88
|
+
const ts = Date.parse(pinnedAt);
|
|
89
|
+
if (!Number.isFinite(ts)) return { stale: true, ageMs: null, pinnedAt };
|
|
90
|
+
const ageMs = now - ts;
|
|
91
|
+
return { stale: ageMs > maxAgeMs, ageMs, pinnedAt };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One warning per distinct (path, pinned_at) per process — loud, not spam. */
|
|
95
|
+
const warnedStalePins = new Set();
|
|
96
|
+
|
|
97
|
+
function warnStalePin(path, pinnedAt, ageMs) {
|
|
98
|
+
const key = `${path} ${pinnedAt ?? '<none>'}`;
|
|
99
|
+
if (warnedStalePins.has(key)) return;
|
|
100
|
+
warnedStalePins.add(key);
|
|
101
|
+
const age = ageMs == null ? 'unknown age' : `${Math.floor(ageMs / 86400000)}d old`;
|
|
102
|
+
process.stderr.write?.(
|
|
103
|
+
`[heyspex-mcp] STALE capture pin REFUSED: ${path} pinned_at=${pinnedAt ?? '<missing>'} (${age}, ` +
|
|
104
|
+
`limit ${Math.floor(CAPTURE_FOCUS_FILE_STALE_MS / 86400000)}d). The console is the system of record for ` +
|
|
105
|
+
`this pin; the offline file has rotted. Nothing is being treated as on deck — re-pin with /hs-capture. ` +
|
|
106
|
+
`[[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]\n`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
41
110
|
/** Frontmatter statuses that mean a capture is no longer on deck. */
|
|
42
111
|
const RETIRED_CAPTURE_STATUSES = new Set(['closed', 'superseded', 'abandoned', 'done']);
|
|
43
112
|
|
|
@@ -139,8 +208,17 @@ export function migrateLegacyIdeaFocus(vaultRoot) {
|
|
|
139
208
|
if (state.claimed_by) lines.push(`claimed_by: ${JSON.stringify(state.claimed_by)}`);
|
|
140
209
|
if (state.claim_session) lines.push(`claim_session: ${JSON.stringify(state.claim_session)}`);
|
|
141
210
|
|
|
142
|
-
|
|
143
|
-
|
|
211
|
+
// Best-effort: this migration runs opportunistically (on boot, and at the
|
|
212
|
+
// top of every loadCaptureFocus) and must never abort the caller's real
|
|
213
|
+
// work over a bad vaultRoot — refuse and report rather than throw.
|
|
214
|
+
try {
|
|
215
|
+
const dir = join(vaultRoot, '.heyspex');
|
|
216
|
+
assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
|
|
217
|
+
mkdirSync(dir, { recursive: true });
|
|
218
|
+
writeFileSync(next, `${lines.join('\n')}\n`, 'utf8');
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return { migrated: false, reason: 'write_failed', error: String(err?.message ?? err) };
|
|
221
|
+
}
|
|
144
222
|
|
|
145
223
|
try {
|
|
146
224
|
const stamped = `${legacy}.migrated-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
@@ -157,7 +235,21 @@ export function migrateLegacyIdeaFocus(vaultRoot) {
|
|
|
157
235
|
return { migrated: true, from: legacy, to: next };
|
|
158
236
|
}
|
|
159
237
|
|
|
160
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Read the offline pin file as-is.
|
|
240
|
+
*
|
|
241
|
+
* Unchanged contract (`focus | null`) — on the FREE tier this file is still the
|
|
242
|
+
* real system of record, so a pin is never dropped here on age alone. What is new
|
|
243
|
+
* (HS-O4U6-2ZRO): the returned object is ANNOTATED with `stale` / `stale_age_ms`
|
|
244
|
+
* so no caller has to guess whether it is holding a current answer. Callers for
|
|
245
|
+
* whom this file is only the offline fallback should use `loadCaptureFocusOffline`,
|
|
246
|
+
* which refuses a stale pin outright.
|
|
247
|
+
*
|
|
248
|
+
* The annotation is read-only metadata — every writer here (`writeCaptureFocus`,
|
|
249
|
+
* `updateCaptureFocusClaim`, `clearCaptureFocusExpiry`) enumerates the fields it
|
|
250
|
+
* persists, so `stale` can never be written back into the YAML.
|
|
251
|
+
*/
|
|
252
|
+
export function loadCaptureFocus(vaultRoot, { now = Date.now() } = {}) {
|
|
161
253
|
migrateLegacyIdeaFocus(vaultRoot);
|
|
162
254
|
const p = captureFocusPath(vaultRoot);
|
|
163
255
|
if (!existsSync(p)) return null;
|
|
@@ -165,16 +257,48 @@ export function loadCaptureFocus(vaultRoot) {
|
|
|
165
257
|
const focus = parseSimpleFocusYaml(readFileSync(p, 'utf-8'));
|
|
166
258
|
if (focus.expires) {
|
|
167
259
|
const expiry = new Date(focus.expires);
|
|
168
|
-
if (expiry < new Date()) return null;
|
|
260
|
+
if (expiry < new Date(now)) return null;
|
|
169
261
|
}
|
|
262
|
+
const { stale, ageMs } = captureFocusStaleness(focus, { now });
|
|
263
|
+
focus.stale = stale;
|
|
264
|
+
focus.stale_age_ms = ageMs;
|
|
170
265
|
return focus;
|
|
171
266
|
} catch {
|
|
172
267
|
return null;
|
|
173
268
|
}
|
|
174
269
|
}
|
|
175
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Read the pin file as an OFFLINE FALLBACK — the seam used when the console (the
|
|
273
|
+
* system of record on a console-backed vault) could not be reached.
|
|
274
|
+
*
|
|
275
|
+
* This is the read that must not lie. A pin older than `CAPTURE_FOCUS_FILE_STALE_MS`
|
|
276
|
+
* is REFUSED (`focus: null`) and a loud one-time warning goes to stderr, because the
|
|
277
|
+
* alternative — handing back a two-month-old pin — makes an offline capture attach to
|
|
278
|
+
* the wrong capture and report success. The result distinguishes the three outcomes
|
|
279
|
+
* the old `focus | null` could not:
|
|
280
|
+
*
|
|
281
|
+
* { focus: {...}, stale: false } → a fresh pin, safe to use
|
|
282
|
+
* { focus: null, stale: false, reason: 'no_pin' } → genuinely nothing pinned
|
|
283
|
+
* { focus: null, stale: true, reason: 'stale', ageMs } → a pin existed and was refused
|
|
284
|
+
*
|
|
285
|
+
* @returns {{ focus: object|null, stale: boolean, ageMs: number|null, pinnedAt: string|null, reason: string }}
|
|
286
|
+
*/
|
|
287
|
+
export function loadCaptureFocusOffline(
|
|
288
|
+
vaultRoot,
|
|
289
|
+
{ now = Date.now(), maxAgeMs = CAPTURE_FOCUS_FILE_STALE_MS } = {},
|
|
290
|
+
) {
|
|
291
|
+
const focus = loadCaptureFocus(vaultRoot, { now });
|
|
292
|
+
if (!focus) return { focus: null, stale: false, ageMs: null, pinnedAt: null, reason: 'no_pin' };
|
|
293
|
+
const { stale, ageMs, pinnedAt } = captureFocusStaleness(focus, { now, maxAgeMs });
|
|
294
|
+
if (!stale) return { focus, stale: false, ageMs, pinnedAt, reason: 'fresh' };
|
|
295
|
+
warnStalePin(captureFocusPath(vaultRoot), pinnedAt, ageMs);
|
|
296
|
+
return { focus: null, stale: true, ageMs, pinnedAt, reason: 'stale' };
|
|
297
|
+
}
|
|
298
|
+
|
|
176
299
|
export function writeCaptureFocus(vaultRoot, specPath, reason, claim, opts = {}) {
|
|
177
300
|
const dir = join(vaultRoot, '.heyspex');
|
|
301
|
+
assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
|
|
178
302
|
mkdirSync(dir, { recursive: true });
|
|
179
303
|
// Mirror the pinned capture's frontmatter `kind:` into the pin so the on-deck
|
|
180
304
|
// pin reflects the real kind (idea | bug-fix | clip | reference | question |
|
|
@@ -204,7 +328,6 @@ export function updateCaptureFocusClaim(vaultRoot, claim) {
|
|
|
204
328
|
const focus = loadCaptureFocus(vaultRoot);
|
|
205
329
|
if (!focus?.spec_path) return false;
|
|
206
330
|
const dir = join(vaultRoot, '.heyspex');
|
|
207
|
-
mkdirSync(dir, { recursive: true });
|
|
208
331
|
const lines = [
|
|
209
332
|
'# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
|
|
210
333
|
`kind: ${focus.kind || 'idea'}`,
|
|
@@ -218,7 +341,19 @@ export function updateCaptureFocusClaim(vaultRoot, claim) {
|
|
|
218
341
|
if (focus.expires) lines.push(`expires: ${focus.expires}`);
|
|
219
342
|
if (claim?.claimed_by) lines.push(`claimed_by: ${JSON.stringify(claim.claimed_by)}`);
|
|
220
343
|
if (claim?.claim_session) lines.push(`claim_session: ${JSON.stringify(claim.claim_session)}`);
|
|
221
|
-
|
|
344
|
+
// Best-effort: callers fire this as a claim-metadata refresh AFTER their
|
|
345
|
+
// primary work already succeeded, and none of them check the return value —
|
|
346
|
+
// a bad vaultRoot here must not blow up an otherwise-successful tool call.
|
|
347
|
+
try {
|
|
348
|
+
assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
|
|
349
|
+
mkdirSync(dir, { recursive: true });
|
|
350
|
+
writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
|
|
351
|
+
} catch (err) {
|
|
352
|
+
process.stderr.write?.(
|
|
353
|
+
`[heyspex-mcp] updateCaptureFocusClaim refused/failed: ${err?.message ?? err}\n`,
|
|
354
|
+
);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
222
357
|
return true;
|
|
223
358
|
}
|
|
224
359
|
|
|
@@ -324,8 +459,18 @@ export const EXPANDED_IDEA_DETAILS_RE = DETAILS_BLOCK_RE;
|
|
|
324
459
|
*/
|
|
325
460
|
export function extractAgentExpansionFromBody(body) {
|
|
326
461
|
const normalized = body.replace(/\r\n/g, '\n');
|
|
462
|
+
// Stop only at a KNOWN sibling section heading (or a top-level `#` / end of
|
|
463
|
+
// body) — not at any `##`. Agent grounding is normally structured prose
|
|
464
|
+
// that opens with its own level-2 heading (e.g. "## Grounding — …"), and
|
|
465
|
+
// the old any-`##` lookahead matched that as the section boundary,
|
|
466
|
+
// silently returning "" for a fully expanded capture. That, in turn,
|
|
467
|
+
// defeated the HS-55YM already-expanded promote bypass (which reads this
|
|
468
|
+
// extractor to decide whether to skip the sufficiency gate) for the normal
|
|
469
|
+
// shape of its own input. Mirrors capture-vault.ts's
|
|
470
|
+
// extractAgentExpansionFromBody byte-for-byte — no drift between the two
|
|
471
|
+
// implementations. [[HS-NHPA-3EFV-DIAG-already-expanded-promote-bypass-is]]
|
|
327
472
|
const section = normalized.match(
|
|
328
|
-
/##[ \t]*Expanded[ \t]+Concept[ \t]*\n([\s\S]*?)(?=\n
|
|
473
|
+
/##[ \t]*Expanded[ \t]+Concept[ \t]*\n([\s\S]*?)(?=\n##[ \t]*(?:Source Material|Notes|Example|User Stories|Done When)[ \t]*\n|\n#[ \t]|$)/i,
|
|
329
474
|
);
|
|
330
475
|
if (section) return stripLeadingHtmlComments(section[1]);
|
|
331
476
|
const m = normalized.match(DETAILS_BLOCK_RE);
|
|
@@ -467,6 +612,127 @@ export function extractSourceMaterialFromBody(body) {
|
|
|
467
612
|
return fence ? fence[1] : stripLeadingHtmlComments(section[1]);
|
|
468
613
|
}
|
|
469
614
|
|
|
615
|
+
/* ------------------------------------------------------------------------ *
|
|
616
|
+
* Source Material — list form (many sources per capture). Mirrors the
|
|
617
|
+
* console `spex-console/src/lib/capture-vault.ts` implementation byte-for-
|
|
618
|
+
* byte (hand-kept in sync, no shared import — see the "3 mirrored builders"
|
|
619
|
+
* lesson). [[HS-9GGC-U1DO-EPIC-source-list-schema-and-one-capture]]
|
|
620
|
+
* ------------------------------------------------------------------------ */
|
|
621
|
+
|
|
622
|
+
export const SOURCE_MATERIAL_GRAINS = ['tab', 'window', 'column', 'folder'];
|
|
623
|
+
|
|
624
|
+
const SOURCE_LIST_ITEM_RE = /^-\s*url:\s*(.+)$/;
|
|
625
|
+
const SOURCE_LIST_FIELD_RE = /^\s+([a-zA-Z_]+):\s*(.*)$/;
|
|
626
|
+
|
|
627
|
+
function parseBoardRefInline(value) {
|
|
628
|
+
const wrapped = value.match(/^\{\s*([\s\S]*?)\s*\}$/);
|
|
629
|
+
if (!wrapped) return undefined;
|
|
630
|
+
const fields = {};
|
|
631
|
+
for (const part of wrapped[1].split(',')) {
|
|
632
|
+
const kv = part.trim().match(/^([a-zA-Z_]+):\s*(.+)$/);
|
|
633
|
+
if (kv) fields[kv[1]] = kv[2].trim();
|
|
634
|
+
}
|
|
635
|
+
if (!fields.board || !fields.column) return undefined;
|
|
636
|
+
return { board: fields.board, column: fields.column, ...(fields.folder ? { folder: fields.folder } : {}) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function parseSourceMaterialListText(text) {
|
|
640
|
+
const entries = [];
|
|
641
|
+
let current = null;
|
|
642
|
+
const flush = () => {
|
|
643
|
+
if (current?.url && current.grain && current.added_at) entries.push(current);
|
|
644
|
+
current = null;
|
|
645
|
+
};
|
|
646
|
+
for (const line of text.split('\n')) {
|
|
647
|
+
const itemMatch = line.match(SOURCE_LIST_ITEM_RE);
|
|
648
|
+
if (itemMatch) {
|
|
649
|
+
flush();
|
|
650
|
+
current = { url: itemMatch[1].trim() };
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (!current) continue;
|
|
654
|
+
const fieldMatch = line.match(SOURCE_LIST_FIELD_RE);
|
|
655
|
+
if (!fieldMatch) continue;
|
|
656
|
+
const [, key, rawValue] = fieldMatch;
|
|
657
|
+
const value = rawValue.trim();
|
|
658
|
+
switch (key) {
|
|
659
|
+
case 'title':
|
|
660
|
+
current.title = value;
|
|
661
|
+
break;
|
|
662
|
+
case 'favicon':
|
|
663
|
+
current.favicon = value;
|
|
664
|
+
break;
|
|
665
|
+
case 'added_at':
|
|
666
|
+
current.added_at = value;
|
|
667
|
+
break;
|
|
668
|
+
case 'grain':
|
|
669
|
+
if (SOURCE_MATERIAL_GRAINS.includes(value)) current.grain = value;
|
|
670
|
+
break;
|
|
671
|
+
case 'board_ref': {
|
|
672
|
+
const ref = parseBoardRefInline(value);
|
|
673
|
+
if (ref) current.board_ref = ref;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case 'tab_id':
|
|
677
|
+
case 'window_id': {
|
|
678
|
+
const n = Number(value);
|
|
679
|
+
if (Number.isFinite(n)) current[key] = n;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
default:
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
flush();
|
|
687
|
+
return entries;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function parseSourceMaterialSection(sectionText) {
|
|
691
|
+
const fence = sectionText.match(SOURCE_FENCE_RE);
|
|
692
|
+
if (fence) return { sources: [], blob: fence[1] };
|
|
693
|
+
const trimmed = sectionText.trim();
|
|
694
|
+
if (!trimmed) return { sources: [], blob: '' };
|
|
695
|
+
if (!/^-\s*url:\s*/m.test(trimmed)) {
|
|
696
|
+
return { sources: [], blob: stripLeadingHtmlComments(sectionText) };
|
|
697
|
+
}
|
|
698
|
+
return { sources: parseSourceMaterialListText(trimmed), blob: '' };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Structured read of `## Source Material` — parses BOTH the legacy single-blob
|
|
703
|
+
* fence and the list form. Use this (not `extractSourceMaterialFromBody`) for
|
|
704
|
+
* any read-then-rebuild path so a list-form section survives an unrelated
|
|
705
|
+
* body rewrite (e.g. `heyspex_update_capture` touching Notes/Core Idea).
|
|
706
|
+
*/
|
|
707
|
+
export function extractSourceMaterialStructured(body) {
|
|
708
|
+
const withoutDetails = stripAgentExpansionFromBody(body);
|
|
709
|
+
const section = withoutDetails.match(
|
|
710
|
+
/##[ \t]*Source[ \t]+Material[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i,
|
|
711
|
+
);
|
|
712
|
+
if (!section) return { sources: [], blob: '' };
|
|
713
|
+
return parseSourceMaterialSection(section[1]);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function formatSourceMaterialEntry(entry) {
|
|
717
|
+
const lines = [`- url: ${entry.url}`];
|
|
718
|
+
if (entry.title) lines.push(` title: ${entry.title}`);
|
|
719
|
+
if (entry.favicon) lines.push(` favicon: ${entry.favicon}`);
|
|
720
|
+
lines.push(` added_at: ${entry.added_at}`);
|
|
721
|
+
lines.push(` grain: ${entry.grain}`);
|
|
722
|
+
if (entry.board_ref) {
|
|
723
|
+
const parts = [`board: ${entry.board_ref.board}`, `column: ${entry.board_ref.column}`];
|
|
724
|
+
if (entry.board_ref.folder) parts.push(`folder: ${entry.board_ref.folder}`);
|
|
725
|
+
lines.push(` board_ref: { ${parts.join(', ')} }`);
|
|
726
|
+
}
|
|
727
|
+
if (typeof entry.tab_id === 'number') lines.push(` tab_id: ${entry.tab_id}`);
|
|
728
|
+
if (typeof entry.window_id === 'number') lines.push(` window_id: ${entry.window_id}`);
|
|
729
|
+
return lines.join('\n');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function buildSourceMaterialList(entries) {
|
|
733
|
+
return entries.map(formatSourceMaterialEntry).join('\n');
|
|
734
|
+
}
|
|
735
|
+
|
|
470
736
|
/**
|
|
471
737
|
* Build the capture body in summaries-first order:
|
|
472
738
|
* **Core Idea → Summary → Notes → Example → User Stories → JTBD → Done When →
|
|
@@ -488,6 +754,7 @@ export function buildCaptureBody(
|
|
|
488
754
|
userStories = '',
|
|
489
755
|
doneWhen = '',
|
|
490
756
|
jtbd = '',
|
|
757
|
+
sources = [],
|
|
491
758
|
) {
|
|
492
759
|
const looksLikeDetails =
|
|
493
760
|
typeof summaryOrAgentExpansion === 'string' && /<details>/i.test(summaryOrAgentExpansion);
|
|
@@ -543,8 +810,11 @@ export function buildCaptureBody(
|
|
|
543
810
|
const expandedBlock = buildAgentExpansionBlock(expandedContent);
|
|
544
811
|
if (expandedBlock) out += expandedBlock.startsWith('\n') ? expandedBlock : `\n${expandedBlock}`;
|
|
545
812
|
|
|
546
|
-
// Source Material — conditional, LAST.
|
|
547
|
-
|
|
813
|
+
// Source Material — conditional, LAST. List form wins when `sources` is
|
|
814
|
+
// populated (many-sources-per-capture); otherwise the legacy fenced blob.
|
|
815
|
+
if (Array.isArray(sources) && sources.length > 0) {
|
|
816
|
+
out += `\n## Source Material\n\n${buildSourceMaterialList(sources)}\n`;
|
|
817
|
+
} else if (sourceTrim) {
|
|
548
818
|
out += `\n## Source Material\n\n\`\`\`source\n${sourceTrim}\n\`\`\`\n`;
|
|
549
819
|
}
|
|
550
820
|
return out;
|
|
@@ -1083,6 +1353,50 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1083
1353
|
return { error: 'CAPTURE_FILE_MISSING', message: specPath };
|
|
1084
1354
|
}
|
|
1085
1355
|
|
|
1356
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
1357
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
1358
|
+
const computed = computeCaptureUpdate(frontmatter, body, args);
|
|
1359
|
+
if (computed.error) return computed;
|
|
1360
|
+
const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
|
|
1361
|
+
|
|
1362
|
+
writeFileSync(fullPath, stringifyCaptureSpec(fm, newBody), 'utf8');
|
|
1363
|
+
|
|
1364
|
+
// Real content just landed → drop the empty-scaffold fuse so this capture is
|
|
1365
|
+
// no longer treated as an abandonable zombie. Runs regardless of whether a
|
|
1366
|
+
// claim refresh follows (a content write without a claim must still defuse).
|
|
1367
|
+
if (hasContent) {
|
|
1368
|
+
clearCaptureFocusExpiry(vaultRoot);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (deps.claim) {
|
|
1372
|
+
updateCaptureFocusClaim(vaultRoot, deps.claim);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
return buildUpdateCaptureResult({
|
|
1376
|
+
fm,
|
|
1377
|
+
newBody,
|
|
1378
|
+
specPath,
|
|
1379
|
+
core,
|
|
1380
|
+
notes,
|
|
1381
|
+
args,
|
|
1382
|
+
autoCreated,
|
|
1383
|
+
continuationOf,
|
|
1384
|
+
summaryWarning,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* The PURE middle of `heyspex_update_capture`: given the capture's current
|
|
1390
|
+
* `{ frontmatter, body }` and the tool args, produce the post-edit
|
|
1391
|
+
* `{ fm, newBody }` pair plus everything the result needs. No I/O at all —
|
|
1392
|
+
* which is what lets the local path `writeFileSync` it and the cloud path hand
|
|
1393
|
+
* the very same pair to `PUT /api/specs/:id` ({@link updateSpecViaConsole})
|
|
1394
|
+
* without two copies of this logic drifting apart.
|
|
1395
|
+
*
|
|
1396
|
+
* Returns a `{ error, message }` result (the shape the tool already returns)
|
|
1397
|
+
* for every rejection.
|
|
1398
|
+
*/
|
|
1399
|
+
function computeCaptureUpdate(frontmatter, body, args = {}) {
|
|
1086
1400
|
// `summary` is the canonical input; `concept` is the deprecated alias (HS-EPIC-OED2).
|
|
1087
1401
|
const summaryArg = args.summary ?? args.concept;
|
|
1088
1402
|
|
|
@@ -1099,8 +1413,6 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1099
1413
|
if (blocked) return blocked;
|
|
1100
1414
|
}
|
|
1101
1415
|
|
|
1102
|
-
const content = readFileSync(fullPath, 'utf-8');
|
|
1103
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
1104
1416
|
let core = extractCaptureCoreFromBody(body);
|
|
1105
1417
|
let notes = extractCaptureNotesFromBody(body);
|
|
1106
1418
|
let summary = extractCaptureSummaryFromBody(body);
|
|
@@ -1190,8 +1502,22 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1190
1502
|
|
|
1191
1503
|
// Preserve any existing `## Source Material` across the rebuild (MCP args don't
|
|
1192
1504
|
// carry source material, but it must survive an update — it used to be dropped).
|
|
1193
|
-
|
|
1194
|
-
|
|
1505
|
+
// Structured read so a LIST-form section (many sources per capture,
|
|
1506
|
+
// [[HS-9GGC-U1DO-EPIC-source-list-schema-and-one-capture]]) survives too —
|
|
1507
|
+
// the plain-string reader would flatten it into a re-fenced blob.
|
|
1508
|
+
const existingSourceMaterial = extractSourceMaterialStructured(body);
|
|
1509
|
+
const newBody = buildCaptureBody(
|
|
1510
|
+
core,
|
|
1511
|
+
notes,
|
|
1512
|
+
summary,
|
|
1513
|
+
agentExpansion,
|
|
1514
|
+
existingSourceMaterial.blob,
|
|
1515
|
+
example,
|
|
1516
|
+
userStories,
|
|
1517
|
+
doneWhen,
|
|
1518
|
+
jtbd,
|
|
1519
|
+
existingSourceMaterial.sources,
|
|
1520
|
+
);
|
|
1195
1521
|
const updated = new Date().toISOString().slice(0, 10);
|
|
1196
1522
|
const fm = { ...frontmatter, updated };
|
|
1197
1523
|
// SV2K: assert type: capture going forward; legacy type: idea is tolerated until E2.
|
|
@@ -1211,11 +1537,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1211
1537
|
if (firstLine) fm.name = firstLine.replace(/^[-*>\s"]+|["]+$/g, '').slice(0, 80);
|
|
1212
1538
|
}
|
|
1213
1539
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
//
|
|
1217
|
-
// no longer treated as an abandonable zombie. Runs regardless of whether a
|
|
1218
|
-
// claim refresh follows (a content write without a claim must still defuse).
|
|
1540
|
+
// Real content just landed → the caller drops the empty-scaffold fuse so this
|
|
1541
|
+
// capture is no longer treated as an abandonable zombie. Computed here (pure)
|
|
1542
|
+
// and acted on by whichever persistence path ran.
|
|
1219
1543
|
const hasContent =
|
|
1220
1544
|
core.trim() !== '' ||
|
|
1221
1545
|
notes.trim() !== '' ||
|
|
@@ -1223,14 +1547,27 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1223
1547
|
example.trim() !== '' ||
|
|
1224
1548
|
doneWhen.trim() !== '' ||
|
|
1225
1549
|
String(agentExpansion ?? '').trim() !== '';
|
|
1226
|
-
if (hasContent) {
|
|
1227
|
-
clearCaptureFocusExpiry(vaultRoot);
|
|
1228
|
-
}
|
|
1229
1550
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
}
|
|
1551
|
+
return { fm, newBody, core, notes, summaryWarning, hasContent };
|
|
1552
|
+
}
|
|
1233
1553
|
|
|
1554
|
+
/**
|
|
1555
|
+
* Shape the `heyspex_update_capture` success payload. Shared verbatim by the
|
|
1556
|
+
* local (disk) and cloud (console) persistence paths so the two can never
|
|
1557
|
+
* report different shapes for the same write.
|
|
1558
|
+
*/
|
|
1559
|
+
function buildUpdateCaptureResult({
|
|
1560
|
+
fm,
|
|
1561
|
+
newBody,
|
|
1562
|
+
specPath,
|
|
1563
|
+
core,
|
|
1564
|
+
notes,
|
|
1565
|
+
args = {},
|
|
1566
|
+
autoCreated,
|
|
1567
|
+
continuationOf,
|
|
1568
|
+
summaryWarning,
|
|
1569
|
+
extra = null,
|
|
1570
|
+
}) {
|
|
1234
1571
|
const result = {
|
|
1235
1572
|
success: true,
|
|
1236
1573
|
spec_id: fm.id,
|
|
@@ -1264,6 +1601,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
|
|
|
1264
1601
|
// the agent elicits missing elements in this pass instead of bouncing at
|
|
1265
1602
|
// promote time. Read-only — never blocks the update or triggers a promote.
|
|
1266
1603
|
result.sufficiency = computeCaptureSufficiency(fm, newBody);
|
|
1604
|
+
// Cloud-only additions (`write_mode` / `on_disk` / `console_url`). Null on the
|
|
1605
|
+
// local path, so that payload stays byte-identical to what it always was.
|
|
1606
|
+
if (extra) Object.assign(result, extra);
|
|
1267
1607
|
return result;
|
|
1268
1608
|
}
|
|
1269
1609
|
|
|
@@ -1280,7 +1620,13 @@ function unwrapWikilink(s) {
|
|
|
1280
1620
|
return m ? m[1] : trimmed;
|
|
1281
1621
|
}
|
|
1282
1622
|
|
|
1283
|
-
export function createFreshCaptureSpec(
|
|
1623
|
+
export function createFreshCaptureSpec(
|
|
1624
|
+
vaultRoot,
|
|
1625
|
+
allocateRandomVaultId,
|
|
1626
|
+
occupiedCaptureIds,
|
|
1627
|
+
lineage = null,
|
|
1628
|
+
deps = {},
|
|
1629
|
+
) {
|
|
1284
1630
|
// Era-3 file-key-first ([[naming-v3-7-rulings-hs-namespace-kept]]): the
|
|
1285
1631
|
// FAMILY token still comes from the legacy allocator's `HS-<family>-CAP`
|
|
1286
1632
|
// draw (parentless capture — no family to inherit); `id:` keys onto the
|
|
@@ -1289,12 +1635,17 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
|
|
|
1289
1635
|
const familyKey = parseSpecId(allocatedFamilyId)?.familyKey ?? mintFamilyKey(vaultRoot);
|
|
1290
1636
|
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
1291
1637
|
// and (V3) the anchor for `id:` itself.
|
|
1292
|
-
|
|
1638
|
+
// Disk occupancy ([[HS-FJWL]]): this is the LOCAL branch of
|
|
1639
|
+
// `createFreshCaptureSpecViaMode` — cloud reliance routes to
|
|
1640
|
+
// `createFreshCaptureSpecViaConsole`, where the console mints the id — so
|
|
1641
|
+
// the vault walk is complete here by construction.
|
|
1642
|
+
const fileKey = mintFileKey(collectOccupiedKeysEitherKind(vaultRoot));
|
|
1293
1643
|
const newId = `HS-${fileKey}`;
|
|
1294
1644
|
const today = new Date().toISOString().slice(0, 10);
|
|
1295
1645
|
const slug = `idea-${today}`;
|
|
1296
1646
|
const filename = `${newId}-${familyKey}-CAP-${slug}.md`;
|
|
1297
1647
|
const dir = join(vaultRoot, 'specs', 'captures');
|
|
1648
|
+
assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
|
|
1298
1649
|
mkdirSync(dir, { recursive: true });
|
|
1299
1650
|
const iso = new Date().toISOString();
|
|
1300
1651
|
const frontmatter = {
|
|
@@ -1305,7 +1656,9 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
|
|
|
1305
1656
|
type: 'capture',
|
|
1306
1657
|
kind: 'idea',
|
|
1307
1658
|
status: 'open',
|
|
1308
|
-
owner
|
|
1659
|
+
// `owner:` is the paired session account, OMITTED when none resolves —
|
|
1660
|
+
// never a literal handle ([[HS-WE87-WW9R-TASK-thread-the-gated-caller-through-every]]).
|
|
1661
|
+
...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
|
|
1309
1662
|
created: iso,
|
|
1310
1663
|
updated: iso,
|
|
1311
1664
|
tags: ['idea', 'capture'],
|
|
@@ -1364,3 +1717,656 @@ export function executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args =
|
|
|
1364
1717
|
message: `Capture ${specId} is now on deck for /hs-capture`,
|
|
1365
1718
|
};
|
|
1366
1719
|
}
|
|
1720
|
+
|
|
1721
|
+
// ---------------------------------------------------------------------------
|
|
1722
|
+
// Cloud vault-write mode
|
|
1723
|
+
// ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]])
|
|
1724
|
+
//
|
|
1725
|
+
// Everything above this line is the LOCAL write path and is untouched: the
|
|
1726
|
+
// markdown goes to `specs/captures/` and the pin goes to the YAML pin file,
|
|
1727
|
+
// exactly as before.
|
|
1728
|
+
//
|
|
1729
|
+
// When `resolveVaultWriteMode()` says `cloud`, neither may touch this machine:
|
|
1730
|
+
//
|
|
1731
|
+
// 1. The capture MARKDOWN (the document itself) is created with
|
|
1732
|
+
// `POST /api/specs` (`type: 'capture'`, server-minted id — nothing here
|
|
1733
|
+
// mints a file key, a family key or an allocator id in cloud mode) and
|
|
1734
|
+
// edited with `PUT /api/specs/:id`.
|
|
1735
|
+
// 2. The PIN is the Neon control plane, not the YAML pin file. Layer-1
|
|
1736
|
+
// (`spec_id` / `pinned_at` / `expires` — including the [[HS-CAP-SID6]]
|
|
1737
|
+
// self-clear fuse) is `PUT /api/control/capture-focus-pin`; layer-2
|
|
1738
|
+
// (`spec_path` / `kind` / `reason` / `claimed_by` / `claim_session`) is
|
|
1739
|
+
// `PUT /api/control/capture-focus-selection`. That is the split
|
|
1740
|
+
// [[HS-M0MK-CPFP-TASK-migrate-capture-focus-yaml-to-neon-capture-focus-pin]]
|
|
1741
|
+
// and [[HS-IIP4-CFL2-TASK-migrate-capture-focus-layer-2-to-neon-capture-focus-selection]]
|
|
1742
|
+
// already shipped behind those routes; IIP4 §Scope item 6 is exactly this
|
|
1743
|
+
// vendor-side repoint, which CPFP deferred as a sync cascade.
|
|
1744
|
+
//
|
|
1745
|
+
// That sync cascade is why the functions below are SIBLINGS rather than an
|
|
1746
|
+
// async-ification of the originals: `writeCaptureFocus` / `executeUpdateCapture`
|
|
1747
|
+
// / `ensureActiveCapturePin` are called from sync paths in cli.mjs and
|
|
1748
|
+
// capture-tools.mjs, and every one of those callers must keep working
|
|
1749
|
+
// unchanged. Each `*ViaMode` entry point resolves the write mode ONCE and
|
|
1750
|
+
// either delegates to its sync twin (`local`) or runs the console path
|
|
1751
|
+
// (`cloud`); the mode can also be passed in, so a caller that already resolved
|
|
1752
|
+
// it does not resolve it twice.
|
|
1753
|
+
//
|
|
1754
|
+
// Every console call takes an injected `consoleFetch`, so tests drive each
|
|
1755
|
+
// branch with a recorder and never open a socket.
|
|
1756
|
+
// ---------------------------------------------------------------------------
|
|
1757
|
+
|
|
1758
|
+
export const CAPTURE_FOCUS_PIN_ROUTE = '/api/control/capture-focus-pin';
|
|
1759
|
+
export const CAPTURE_FOCUS_SELECTION_ROUTE = '/api/control/capture-focus-selection';
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* The capture spec id a `spec_path` names.
|
|
1763
|
+
*
|
|
1764
|
+
* A local twin of capture-tools.mjs's `captureSpecIdFromPath` on purpose: that
|
|
1765
|
+
* module imports THIS one, so importing it back would be a cycle — and its
|
|
1766
|
+
* regex only reads era-1/2 `HS-CAP-XXXX` names, while a capture filed today is
|
|
1767
|
+
* era-3 (`HS-<fileKey>-<familyKey>-CAP-<slug>`, keyed on the file key alone).
|
|
1768
|
+
* Both eras are read here through the shared `parseSpecId`.
|
|
1769
|
+
*/
|
|
1770
|
+
export function captureSpecIdFromSpecPath(specPath) {
|
|
1771
|
+
const base = String(specPath || '')
|
|
1772
|
+
.replace(/^.*[/\\]/, '')
|
|
1773
|
+
.replace(/\.md$/i, '');
|
|
1774
|
+
if (!base) return null;
|
|
1775
|
+
const parsed = parseSpecId(base);
|
|
1776
|
+
if (parsed?.fileKey) return `HS-${parsed.fileKey}`;
|
|
1777
|
+
if (parsed?.typeLabel && parsed?.familyKey) return `HS-${parsed.typeLabel}-${parsed.familyKey}`;
|
|
1778
|
+
return base;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Default cloud-tier spec read. Dynamically imported so this module keeps no
|
|
1783
|
+
* static edge to vault-io.mjs, and injectable because `loadSpecByIdViaTier`'s
|
|
1784
|
+
* neon branch closes over the real `consoleFetch` (a test must be able to
|
|
1785
|
+
* replace the whole read, not just the transport).
|
|
1786
|
+
*/
|
|
1787
|
+
async function defaultLoadSpecViaTier(vaultRoot, ref, consoleUrl) {
|
|
1788
|
+
const { loadSpecByIdViaTier } = await import('./vault-io.mjs');
|
|
1789
|
+
return loadSpecByIdViaTier(vaultRoot, ref, { tier: 'neon', consoleUrl });
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** Normalize the cloud dependency bundle every `*ViaConsole` function takes. */
|
|
1793
|
+
export function captureCloudDeps(deps = {}) {
|
|
1794
|
+
return {
|
|
1795
|
+
consoleFetch: deps.consoleFetch ?? realConsoleFetch,
|
|
1796
|
+
consoleUrl: deps.consoleUrl ?? getConsoleUrl(),
|
|
1797
|
+
loadSpecViaTier: deps.loadSpecViaTier ?? defaultLoadSpecViaTier,
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** Resolve the write mode ONCE for a handler entry; `?? 'local'` keeps callers unchanged. */
|
|
1802
|
+
async function resolveWriteModeOnce(explicit, deps = {}) {
|
|
1803
|
+
return explicit ?? (await (deps.resolveWriteMode ?? resolveVaultWriteMode)()) ?? 'local';
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Read the live capture pin from the Neon control plane: layer-1 ⊕ layer-2,
|
|
1808
|
+
* composed the same way the console's `readCaptureFocusState` does — layer-2 is
|
|
1809
|
+
* attached only when its `spec_path` names the pinned spec, so a stale
|
|
1810
|
+
* selection never paints onto a freshly-pinned one.
|
|
1811
|
+
*
|
|
1812
|
+
* Deliberately NOT `readCaptureFocusVendor` (capture-tools.mjs): that reader
|
|
1813
|
+
* falls back to the offline YAML pin file, and its `loadCaptureFocus` call runs
|
|
1814
|
+
* `migrateLegacyIdeaFocus`, which WRITES that file. Both are exactly what cloud
|
|
1815
|
+
* mode forbids, so this path is strict — it throws
|
|
1816
|
+
* `CONSOLE_UNREACHABLE_CLOUD_MODE` rather than reading (or writing) the disk.
|
|
1817
|
+
*
|
|
1818
|
+
* @returns the same `focus` shape `loadCaptureFocus` returns, or `null` when
|
|
1819
|
+
* Neon says nothing is pinned (an authoritative answer, not a failure).
|
|
1820
|
+
*/
|
|
1821
|
+
export async function readCaptureFocusViaConsole(deps = {}) {
|
|
1822
|
+
const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
|
|
1823
|
+
const [pinRes, selRes] = await Promise.all([
|
|
1824
|
+
consoleFetch(CAPTURE_FOCUS_PIN_ROUTE),
|
|
1825
|
+
consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE),
|
|
1826
|
+
]);
|
|
1827
|
+
if (!pinRes?.ok) {
|
|
1828
|
+
throw cloudWriteFailure(
|
|
1829
|
+
`GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
|
|
1830
|
+
consoleUrl,
|
|
1831
|
+
`answered ${pinRes?.status ?? 'no response'}`,
|
|
1832
|
+
{ status: pinRes?.status ?? null, console_response: pinRes?.body ?? null },
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
const pin = pinRes.body?.pin ?? null;
|
|
1836
|
+
if (!pin) return null;
|
|
1837
|
+
const sel = selRes?.ok ? (selRes.body?.selection ?? null) : null;
|
|
1838
|
+
const basenameOf = (p) => String(p || '').replace(/^.*[/\\]/, '');
|
|
1839
|
+
const selMatches = sel?.specPath ? basenameOf(sel.specPath).startsWith(pin.specId) : false;
|
|
1840
|
+
const focus = {
|
|
1841
|
+
spec_id: pin.specId,
|
|
1842
|
+
spec_path: selMatches ? sel.specPath : `specs/captures/${pin.specId}.md`,
|
|
1843
|
+
pinned_at: pin.pinnedAt ?? null,
|
|
1844
|
+
};
|
|
1845
|
+
if (pin.expiresAt) focus.expires = pin.expiresAt;
|
|
1846
|
+
if (selMatches && sel) {
|
|
1847
|
+
if (sel.reason) focus.reason = sel.reason;
|
|
1848
|
+
if (sel.claimedBy) focus.claimed_by = sel.claimedBy;
|
|
1849
|
+
if (sel.claimSession) focus.claim_session = sel.claimSession;
|
|
1850
|
+
if (sel.kind) focus.kind = sel.kind;
|
|
1851
|
+
}
|
|
1852
|
+
if (!focus.kind) focus.kind = 'idea';
|
|
1853
|
+
return focus;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Cloud twin of {@link writeCaptureFocus}: pin layer-1 + layer-2 in one call.
|
|
1858
|
+
* The YAML pin file is never touched.
|
|
1859
|
+
*
|
|
1860
|
+
* `expires` (the empty-scaffold silent fuse) is carried as the pin route's
|
|
1861
|
+
* `ttl_ms`, which is how `capture_focus_pin` stores it — `getCaptureFocusPin`'s
|
|
1862
|
+
* SQL already excludes rows past `expires_at`, so the [[HS-CAP-SID6]] self-retire
|
|
1863
|
+
* behaves identically on this tier (CPFP AC#2).
|
|
1864
|
+
*/
|
|
1865
|
+
export async function writeCaptureFocusViaConsole(
|
|
1866
|
+
{ specId = null, specPath, reason = null, claim = null, kind = 'idea', expires = null } = {},
|
|
1867
|
+
deps = {},
|
|
1868
|
+
) {
|
|
1869
|
+
const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
|
|
1870
|
+
const id = specId || captureSpecIdFromSpecPath(specPath);
|
|
1871
|
+
if (!id) {
|
|
1872
|
+
throw cloudWriteFailure(
|
|
1873
|
+
`PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
|
|
1874
|
+
consoleUrl,
|
|
1875
|
+
`no capture spec id could be resolved from spec_path ${JSON.stringify(specPath ?? null)} — the pin route requires one`,
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
const ttlMs = expires ? Date.parse(expires) - Date.now() : null;
|
|
1879
|
+
const [pinRes, selRes] = await Promise.all([
|
|
1880
|
+
consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
|
|
1881
|
+
method: 'PUT',
|
|
1882
|
+
body: {
|
|
1883
|
+
spec_id: id,
|
|
1884
|
+
pinned_by: claim?.claimed_by || 'agent',
|
|
1885
|
+
...(Number.isFinite(ttlMs) ? { ttl_ms: ttlMs } : {}),
|
|
1886
|
+
},
|
|
1887
|
+
}),
|
|
1888
|
+
consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
|
|
1889
|
+
method: 'PUT',
|
|
1890
|
+
body: {
|
|
1891
|
+
spec_path: specPath ?? null,
|
|
1892
|
+
kind: typeof kind === 'string' && kind.trim() ? kind.trim() : 'idea',
|
|
1893
|
+
reason: reason ?? null,
|
|
1894
|
+
claimed_by: claim?.claimed_by ?? null,
|
|
1895
|
+
claim_session: claim?.claim_session ?? null,
|
|
1896
|
+
},
|
|
1897
|
+
}),
|
|
1898
|
+
]);
|
|
1899
|
+
if (!pinRes?.ok || !selRes?.ok) {
|
|
1900
|
+
throw cloudWriteFailure(
|
|
1901
|
+
`PUT ${CAPTURE_FOCUS_PIN_ROUTE} + ${CAPTURE_FOCUS_SELECTION_ROUTE}`,
|
|
1902
|
+
consoleUrl,
|
|
1903
|
+
`pin answered ${pinRes?.status ?? 'no response'}, selection answered ${selRes?.status ?? 'no response'} — the capture is NOT on deck`,
|
|
1904
|
+
{
|
|
1905
|
+
pin_status: pinRes?.status ?? null,
|
|
1906
|
+
selection_status: selRes?.status ?? null,
|
|
1907
|
+
spec_id: id,
|
|
1908
|
+
spec_path: specPath ?? null,
|
|
1909
|
+
},
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
return {
|
|
1913
|
+
pinned: true,
|
|
1914
|
+
tier: 'neon',
|
|
1915
|
+
on_disk: false,
|
|
1916
|
+
spec_id: id,
|
|
1917
|
+
spec_path: specPath ?? null,
|
|
1918
|
+
pin: pinRes.body?.pin ?? null,
|
|
1919
|
+
selection: selRes.body?.selection ?? null,
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Cloud twin of {@link updateCaptureFocusClaim} — refresh the claim metadata on
|
|
1925
|
+
* the LIVE pin. Layer-2 only: the claim lives in `capture_focus_selection`, so
|
|
1926
|
+
* the layer-1 row (and with it `pinned_at` and the fuse) is left alone.
|
|
1927
|
+
*
|
|
1928
|
+
* `setCaptureFocusSelection` replaces the whole row, so the fields this refresh
|
|
1929
|
+
* is not changing (`spec_path` / `kind` / `reason`) are re-sent from the row as
|
|
1930
|
+
* read. Best-effort like its sync twin: callers fire it after their real work
|
|
1931
|
+
* succeeded and none check the return value, so a failure reports `false` and a
|
|
1932
|
+
* stderr line rather than throwing.
|
|
1933
|
+
*/
|
|
1934
|
+
export async function updateCaptureFocusClaimViaConsole(claim, deps = {}) {
|
|
1935
|
+
const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
|
|
1936
|
+
try {
|
|
1937
|
+
const focus = await readCaptureFocusViaConsole({ consoleFetch, consoleUrl });
|
|
1938
|
+
if (!focus?.spec_path) return false;
|
|
1939
|
+
const res = await consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
|
|
1940
|
+
method: 'PUT',
|
|
1941
|
+
body: {
|
|
1942
|
+
spec_path: focus.spec_path,
|
|
1943
|
+
kind: focus.kind || 'idea',
|
|
1944
|
+
reason: focus.reason ?? null,
|
|
1945
|
+
claimed_by: claim?.claimed_by ?? null,
|
|
1946
|
+
claim_session: claim?.claim_session ?? null,
|
|
1947
|
+
},
|
|
1948
|
+
});
|
|
1949
|
+
if (!res?.ok) {
|
|
1950
|
+
process.stderr.write?.(
|
|
1951
|
+
`[heyspex-mcp] updateCaptureFocusClaimViaConsole refused: ${CAPTURE_FOCUS_SELECTION_ROUTE} answered ${res?.status ?? 'no response'}\n`,
|
|
1952
|
+
);
|
|
1953
|
+
return false;
|
|
1954
|
+
}
|
|
1955
|
+
return true;
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
process.stderr.write?.(
|
|
1958
|
+
`[heyspex-mcp] updateCaptureFocusClaimViaConsole failed: ${err?.message ?? err}\n`,
|
|
1959
|
+
);
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Cloud twin of {@link clearCaptureFocusExpiry}: drop the empty-scaffold fuse
|
|
1966
|
+
* once a capture has real content, by re-pinning layer-1 with `ttl_ms: null`.
|
|
1967
|
+
* Same no-op contract as the sync twin — `false` when nothing is pinned or the
|
|
1968
|
+
* pin carries no fuse.
|
|
1969
|
+
*/
|
|
1970
|
+
export async function clearCaptureFocusExpiryViaConsole(deps = {}) {
|
|
1971
|
+
const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
|
|
1972
|
+
const pinRes = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE);
|
|
1973
|
+
if (!pinRes?.ok) {
|
|
1974
|
+
throw cloudWriteFailure(
|
|
1975
|
+
`GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
|
|
1976
|
+
consoleUrl,
|
|
1977
|
+
`answered ${pinRes?.status ?? 'no response'}`,
|
|
1978
|
+
{ status: pinRes?.status ?? null },
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
const pin = pinRes.body?.pin ?? null;
|
|
1982
|
+
if (!pin?.specId || !pin.expiresAt) return false;
|
|
1983
|
+
const res = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
|
|
1984
|
+
method: 'PUT',
|
|
1985
|
+
body: { spec_id: pin.specId, pinned_by: pin.pinnedBy || 'agent', ttl_ms: null },
|
|
1986
|
+
});
|
|
1987
|
+
if (!res?.ok) {
|
|
1988
|
+
throw cloudWriteFailure(
|
|
1989
|
+
`PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
|
|
1990
|
+
consoleUrl,
|
|
1991
|
+
`answered ${res?.status ?? 'no response'} clearing the capture-pin fuse`,
|
|
1992
|
+
{ status: res?.status ?? null, spec_id: pin.specId },
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Cloud twin of {@link createFreshCaptureSpec}.
|
|
2000
|
+
*
|
|
2001
|
+
* NOTHING is minted client-side here — no `allocateRandomVaultId`, no
|
|
2002
|
+
* `mintFamilyKey`, no `mintFileKey`. The console mints the id, the family and
|
|
2003
|
+
* the filename (`createSpec`: `capture` → `specs/captures/`, `CAP`), which is
|
|
2004
|
+
* the whole point: a key minted on a machine whose vault the console has never
|
|
2005
|
+
* indexed collides with a cloud-side one ([[HS-2FQY]]).
|
|
2006
|
+
*/
|
|
2007
|
+
export async function createFreshCaptureSpecViaConsole(vaultRoot, lineage = null, deps = {}) {
|
|
2008
|
+
const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
|
|
2009
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2010
|
+
// No `status:` — the console seeds `open` and refuses an out-of-band status
|
|
2011
|
+
// on create ([[HS-ISZP]]); the disk twin above keeps it because it writes
|
|
2012
|
+
// the frontmatter itself.
|
|
2013
|
+
const frontmatter = {
|
|
2014
|
+
kind: 'idea',
|
|
2015
|
+
// Same owner rule as the disk twin: the paired account or no key at all.
|
|
2016
|
+
...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
|
|
2017
|
+
tags: ['idea', 'capture'],
|
|
2018
|
+
promoted_to: null,
|
|
2019
|
+
capture_status: 'raw',
|
|
2020
|
+
// HS-KFB2 lineage carry-over — identical to the local path.
|
|
2021
|
+
...(lineage?.promotedTo ? { continues: `[[${unwrapWikilink(lineage.promotedTo)}]]` } : {}),
|
|
2022
|
+
};
|
|
2023
|
+
const body = lineage?.promotedTo
|
|
2024
|
+
? CAPTURE_BODY_TEMPLATE.replace(
|
|
2025
|
+
/^# Core Idea\n/,
|
|
2026
|
+
`# Core Idea\n\n> Continuation of a recently-promoted idea — see [[${unwrapWikilink(lineage.promotedTo)}]]` +
|
|
2027
|
+
(lineage.retiredCaptureBasename
|
|
2028
|
+
? ` (originally [[${unwrapWikilink(lineage.retiredCaptureBasename)}]]).`
|
|
2029
|
+
: '.') +
|
|
2030
|
+
'\n',
|
|
2031
|
+
)
|
|
2032
|
+
: CAPTURE_BODY_TEMPLATE;
|
|
2033
|
+
const created = await createSpecViaConsole({
|
|
2034
|
+
consoleFetch,
|
|
2035
|
+
consoleUrl,
|
|
2036
|
+
type: 'capture',
|
|
2037
|
+
name: `Capture ${today}`,
|
|
2038
|
+
frontmatter,
|
|
2039
|
+
body,
|
|
2040
|
+
});
|
|
2041
|
+
return {
|
|
2042
|
+
spec_id: created.specId,
|
|
2043
|
+
spec_path: created.relPath ?? `specs/captures/${created.basename}.md`,
|
|
2044
|
+
on_disk: false,
|
|
2045
|
+
...(lineage?.promotedTo ? { continuation_of: unwrapWikilink(lineage.promotedTo) } : {}),
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Mode-aware {@link createFreshCaptureSpec}. `local` delegates verbatim.
|
|
2051
|
+
* In `cloud` the allocator arguments are IGNORED (the server mints) — they stay
|
|
2052
|
+
* in the signature only so this is a drop-in for the sync call sites.
|
|
2053
|
+
*/
|
|
2054
|
+
export async function createFreshCaptureSpecViaMode(
|
|
2055
|
+
vaultRoot,
|
|
2056
|
+
allocateRandomVaultId,
|
|
2057
|
+
occupiedCaptureIds,
|
|
2058
|
+
lineage = null,
|
|
2059
|
+
deps = {},
|
|
2060
|
+
) {
|
|
2061
|
+
const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
|
|
2062
|
+
if (writeMode !== 'cloud') {
|
|
2063
|
+
return createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage, deps);
|
|
2064
|
+
}
|
|
2065
|
+
return createFreshCaptureSpecViaConsole(vaultRoot, lineage, deps);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** Mode-aware {@link writeCaptureFocus}. */
|
|
2069
|
+
export async function writeCaptureFocusViaMode(vaultRoot, specPath, reason, claim, opts = {}, deps = {}) {
|
|
2070
|
+
const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
|
|
2071
|
+
if (writeMode !== 'cloud') {
|
|
2072
|
+
writeCaptureFocus(vaultRoot, specPath, reason, claim, opts);
|
|
2073
|
+
return { pinned: true, tier: 'file', on_disk: true, spec_path: specPath };
|
|
2074
|
+
}
|
|
2075
|
+
return writeCaptureFocusViaConsole(
|
|
2076
|
+
{
|
|
2077
|
+
specId: opts.specId ?? null,
|
|
2078
|
+
specPath,
|
|
2079
|
+
reason,
|
|
2080
|
+
claim,
|
|
2081
|
+
kind: opts.kind,
|
|
2082
|
+
expires: opts.expires ?? null,
|
|
2083
|
+
},
|
|
2084
|
+
deps,
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** Mode-aware {@link updateCaptureFocusClaim}. */
|
|
2089
|
+
export async function updateCaptureFocusClaimViaMode(vaultRoot, claim, deps = {}) {
|
|
2090
|
+
const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
|
|
2091
|
+
if (writeMode !== 'cloud') return updateCaptureFocusClaim(vaultRoot, claim);
|
|
2092
|
+
return updateCaptureFocusClaimViaConsole(claim, deps);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** Mode-aware {@link clearCaptureFocusExpiry}. */
|
|
2096
|
+
export async function clearCaptureFocusExpiryViaMode(vaultRoot, deps = {}) {
|
|
2097
|
+
const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
|
|
2098
|
+
if (writeMode !== 'cloud') return clearCaptureFocusExpiry(vaultRoot);
|
|
2099
|
+
return clearCaptureFocusExpiryViaConsole(deps);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Mode-aware {@link ensureActiveCapturePin}.
|
|
2104
|
+
*
|
|
2105
|
+
* The cloud branch reuses {@link resolveActiveCapture} rather than restating its
|
|
2106
|
+
* retirement / recently-promoted-lineage rules: that function already takes both
|
|
2107
|
+
* a `focusOverride` and an injected `loadSpec`, so the console-sourced pin and a
|
|
2108
|
+
* pre-fetched spec are handed straight to it and the SID6 / HS-KFB2 behavior is
|
|
2109
|
+
* the same code on both tiers.
|
|
2110
|
+
*
|
|
2111
|
+
* The `AUTO_CREATE_UNAVAILABLE` guard is local-only by construction: cloud mode
|
|
2112
|
+
* needs no id allocator because the console mints the id.
|
|
2113
|
+
*/
|
|
2114
|
+
export async function ensureActiveCapturePinViaMode(
|
|
2115
|
+
vaultRoot,
|
|
2116
|
+
parseFrontmatter,
|
|
2117
|
+
loadSpec,
|
|
2118
|
+
deps = {},
|
|
2119
|
+
cloudDeps = {},
|
|
2120
|
+
) {
|
|
2121
|
+
const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
|
|
2122
|
+
if (writeMode !== 'cloud') {
|
|
2123
|
+
return ensureActiveCapturePin(vaultRoot, parseFrontmatter, loadSpec, deps);
|
|
2124
|
+
}
|
|
2125
|
+
const ctx = captureCloudDeps(cloudDeps);
|
|
2126
|
+
const { pinReason = 'Auto-created via MCP', claim = null } = deps;
|
|
2127
|
+
|
|
2128
|
+
const focus = await readCaptureFocusViaConsole(ctx);
|
|
2129
|
+
let pinnedSpec = null;
|
|
2130
|
+
if (focus?.spec_path) {
|
|
2131
|
+
pinnedSpec = await ctx.loadSpecViaTier(
|
|
2132
|
+
vaultRoot,
|
|
2133
|
+
focus.spec_id ?? captureSpecIdFromSpecPath(focus.spec_path),
|
|
2134
|
+
ctx.consoleUrl,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
let resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => pinnedSpec, focus ?? null);
|
|
2138
|
+
if (!resolved.error) {
|
|
2139
|
+
if (claim) await updateCaptureFocusClaimViaConsole(claim, ctx);
|
|
2140
|
+
return { resolved, fresh: false, auto_created: false };
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const previousError = resolved.error;
|
|
2144
|
+
const recoverable =
|
|
2145
|
+
resolved.error === 'NO_CAPTURE_PINNED' ||
|
|
2146
|
+
resolved.error === 'CAPTURE_NOT_FOUND' ||
|
|
2147
|
+
resolved.error === 'CAPTURE_RETIRED';
|
|
2148
|
+
if (!recoverable) {
|
|
2149
|
+
return { error: resolved.error, message: resolved.message };
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
const lineage =
|
|
2153
|
+
resolved.recentlyPromoted && resolved.promotedTo
|
|
2154
|
+
? { promotedTo: resolved.promotedTo, retiredCaptureBasename: resolved.retiredCaptureBasename }
|
|
2155
|
+
: null;
|
|
2156
|
+
|
|
2157
|
+
const created = await createFreshCaptureSpecViaConsole(vaultRoot, lineage, ctx);
|
|
2158
|
+
const expires = new Date(Date.now() + CAPTURE_PIN_FUSE_MS).toISOString();
|
|
2159
|
+
await writeCaptureFocusViaConsole(
|
|
2160
|
+
{ specId: created.spec_id, specPath: created.spec_path, reason: pinReason, claim, expires },
|
|
2161
|
+
ctx,
|
|
2162
|
+
);
|
|
2163
|
+
|
|
2164
|
+
const freshSpec = await ctx.loadSpecViaTier(vaultRoot, created.spec_id, ctx.consoleUrl);
|
|
2165
|
+
resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => freshSpec, {
|
|
2166
|
+
spec_id: created.spec_id,
|
|
2167
|
+
spec_path: created.spec_path,
|
|
2168
|
+
pinned_at: new Date().toISOString(),
|
|
2169
|
+
expires,
|
|
2170
|
+
kind: 'idea',
|
|
2171
|
+
reason: pinReason,
|
|
2172
|
+
});
|
|
2173
|
+
if (resolved.error) {
|
|
2174
|
+
return {
|
|
2175
|
+
error: resolved.error,
|
|
2176
|
+
message: resolved.message || 'Auto-create failed after pin write.',
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
return {
|
|
2180
|
+
resolved,
|
|
2181
|
+
fresh: true,
|
|
2182
|
+
auto_created: true,
|
|
2183
|
+
replaced_stale_pin: previousError === 'CAPTURE_NOT_FOUND',
|
|
2184
|
+
...(created.continuation_of ? { continuation_of: created.continuation_of } : {}),
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Mode-aware {@link executeUpdateCapture}.
|
|
2190
|
+
*
|
|
2191
|
+
* Cloud branch: the capture is sourced through the cloud read tier
|
|
2192
|
+
* (`loadSpecByIdViaTier`, the read half this repo already shipped — nothing new
|
|
2193
|
+
* is written here to fetch a spec), mutated by the SAME pure
|
|
2194
|
+
* `computeCaptureUpdate` the local path runs, and persisted with
|
|
2195
|
+
* `PUT /api/specs/:id`. The fuse-clear and claim-refresh follow-ups go to the
|
|
2196
|
+
* console pin routes instead of the YAML pin file.
|
|
2197
|
+
*/
|
|
2198
|
+
export async function executeUpdateCaptureViaMode(
|
|
2199
|
+
vaultRoot,
|
|
2200
|
+
parseFrontmatter,
|
|
2201
|
+
args = {},
|
|
2202
|
+
deps = {},
|
|
2203
|
+
cloudDeps = {},
|
|
2204
|
+
) {
|
|
2205
|
+
const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
|
|
2206
|
+
if (writeMode !== 'cloud') {
|
|
2207
|
+
return executeUpdateCapture(vaultRoot, parseFrontmatter, args, deps);
|
|
2208
|
+
}
|
|
2209
|
+
const ctx = captureCloudDeps(cloudDeps);
|
|
2210
|
+
|
|
2211
|
+
let specPath = null;
|
|
2212
|
+
let specId = null;
|
|
2213
|
+
let loaded = null;
|
|
2214
|
+
let autoCreated = false;
|
|
2215
|
+
let continuationOf = null;
|
|
2216
|
+
|
|
2217
|
+
if (args.spec_id) {
|
|
2218
|
+
loaded = await ctx.loadSpecViaTier(vaultRoot, args.spec_id, ctx.consoleUrl);
|
|
2219
|
+
if (!loaded) {
|
|
2220
|
+
return { error: 'SPEC_NOT_FOUND', message: `Capture ${args.spec_id} not found` };
|
|
2221
|
+
}
|
|
2222
|
+
specPath = loaded.specPath ?? null;
|
|
2223
|
+
specId = loaded.frontmatter?.id ?? args.spec_id;
|
|
2224
|
+
} else {
|
|
2225
|
+
const ensured = await ensureActiveCapturePinViaMode(
|
|
2226
|
+
vaultRoot,
|
|
2227
|
+
parseFrontmatter,
|
|
2228
|
+
deps.loadSpec,
|
|
2229
|
+
{
|
|
2230
|
+
pinReason: 'Auto-created via heyspex_update_capture',
|
|
2231
|
+
claim: deps.claim ?? null,
|
|
2232
|
+
},
|
|
2233
|
+
{ ...cloudDeps, writeMode: 'cloud' },
|
|
2234
|
+
);
|
|
2235
|
+
if (ensured.error) {
|
|
2236
|
+
return { error: ensured.error, message: ensured.message };
|
|
2237
|
+
}
|
|
2238
|
+
autoCreated = ensured.auto_created === true;
|
|
2239
|
+
continuationOf = ensured.continuation_of ?? null;
|
|
2240
|
+
specPath = ensured.resolved.spec_path;
|
|
2241
|
+
specId = ensured.resolved.spec_id;
|
|
2242
|
+
loaded = { frontmatter: ensured.resolved.frontmatter, body: ensured.resolved.body, specPath };
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const computed = computeCaptureUpdate(loaded.frontmatter ?? {}, loaded.body ?? '', args);
|
|
2246
|
+
if (computed.error) return computed;
|
|
2247
|
+
const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
|
|
2248
|
+
|
|
2249
|
+
const targetId = fm.id ?? specId;
|
|
2250
|
+
if (!targetId) {
|
|
2251
|
+
return {
|
|
2252
|
+
error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
|
|
2253
|
+
message:
|
|
2254
|
+
`Capture ${specPath ?? '(unknown path)'} carries no \`id:\` frontmatter, and PUT /api/specs/:id needs one. `
|
|
2255
|
+
+ 'NOTHING WAS WRITTEN — the local disk write is deliberately NOT used as a fallback in cloud vault-write mode.',
|
|
2256
|
+
spec_path: specPath,
|
|
2257
|
+
write_mode: 'cloud',
|
|
2258
|
+
on_disk: false,
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
let written;
|
|
2263
|
+
try {
|
|
2264
|
+
written = await updateSpecViaConsole({
|
|
2265
|
+
consoleFetch: ctx.consoleFetch,
|
|
2266
|
+
consoleUrl: ctx.consoleUrl,
|
|
2267
|
+
specId: targetId,
|
|
2268
|
+
frontmatter: fm,
|
|
2269
|
+
body: newBody,
|
|
2270
|
+
});
|
|
2271
|
+
} catch (err) {
|
|
2272
|
+
return {
|
|
2273
|
+
error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
|
|
2274
|
+
message: String(err?.message ?? err),
|
|
2275
|
+
spec_id: targetId,
|
|
2276
|
+
spec_path: specPath,
|
|
2277
|
+
write_mode: 'cloud',
|
|
2278
|
+
on_disk: false,
|
|
2279
|
+
...(err?.notFound ? { not_found: true } : {}),
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
if (hasContent) {
|
|
2284
|
+
// Best-effort, exactly like the local fuse-clear: a fuse that cannot be
|
|
2285
|
+
// dropped must not fail an update whose content already landed.
|
|
2286
|
+
try {
|
|
2287
|
+
await clearCaptureFocusExpiryViaConsole(ctx);
|
|
2288
|
+
} catch (err) {
|
|
2289
|
+
process.stderr.write?.(
|
|
2290
|
+
`[heyspex-mcp] capture fuse not cleared through the console: ${err?.message ?? err}\n`,
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (deps.claim) {
|
|
2295
|
+
await updateCaptureFocusClaimViaConsole(deps.claim, ctx);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
return buildUpdateCaptureResult({
|
|
2299
|
+
fm,
|
|
2300
|
+
newBody,
|
|
2301
|
+
specPath: written.relPath ?? specPath,
|
|
2302
|
+
core,
|
|
2303
|
+
notes,
|
|
2304
|
+
args,
|
|
2305
|
+
autoCreated,
|
|
2306
|
+
continuationOf,
|
|
2307
|
+
summaryWarning,
|
|
2308
|
+
extra: { write_mode: 'cloud', on_disk: false, console_url: written.consoleUrl ?? ctx.consoleUrl },
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* Mode-aware {@link executePinCapture}. Cloud resolves the capture through the
|
|
2314
|
+
* cloud read tier and pins it on the Neon control plane; the YAML pin file is
|
|
2315
|
+
* never written.
|
|
2316
|
+
*/
|
|
2317
|
+
export async function executePinCaptureViaMode(
|
|
2318
|
+
vaultRoot,
|
|
2319
|
+
parseFrontmatter,
|
|
2320
|
+
loadSpec,
|
|
2321
|
+
args = {},
|
|
2322
|
+
cloudDeps = {},
|
|
2323
|
+
) {
|
|
2324
|
+
const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
|
|
2325
|
+
if (writeMode !== 'cloud') {
|
|
2326
|
+
return executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args);
|
|
2327
|
+
}
|
|
2328
|
+
const ctx = captureCloudDeps(cloudDeps);
|
|
2329
|
+
const specId = args.spec_id;
|
|
2330
|
+
if (!specId) {
|
|
2331
|
+
return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
|
|
2332
|
+
}
|
|
2333
|
+
const spec = await ctx.loadSpecViaTier(vaultRoot, specId, ctx.consoleUrl);
|
|
2334
|
+
if (!spec) {
|
|
2335
|
+
return { error: 'SPEC_NOT_FOUND', message: `Capture ${specId} not found` };
|
|
2336
|
+
}
|
|
2337
|
+
const fmType = spec.frontmatter?.type;
|
|
2338
|
+
if (fmType !== 'capture' && fmType !== 'idea') {
|
|
2339
|
+
return { error: 'NOT_A_CAPTURE', message: `${specId} is not type: capture` };
|
|
2340
|
+
}
|
|
2341
|
+
const specPath = spec.specPath ?? null;
|
|
2342
|
+
const pinnedKind = spec.frontmatter?.kind;
|
|
2343
|
+
try {
|
|
2344
|
+
await writeCaptureFocusViaConsole(
|
|
2345
|
+
{
|
|
2346
|
+
specId: spec.frontmatter?.id ?? specId,
|
|
2347
|
+
specPath,
|
|
2348
|
+
reason: args.reason || 'Pinned via MCP',
|
|
2349
|
+
claim: null,
|
|
2350
|
+
kind: typeof pinnedKind === 'string' ? pinnedKind : 'idea',
|
|
2351
|
+
},
|
|
2352
|
+
ctx,
|
|
2353
|
+
);
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
return {
|
|
2356
|
+
error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
|
|
2357
|
+
message: String(err?.message ?? err),
|
|
2358
|
+
spec_id: specId,
|
|
2359
|
+
spec_path: specPath,
|
|
2360
|
+
write_mode: 'cloud',
|
|
2361
|
+
on_disk: false,
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
return {
|
|
2365
|
+
success: true,
|
|
2366
|
+
spec_id: specId,
|
|
2367
|
+
spec_path: specPath,
|
|
2368
|
+
write_mode: 'cloud',
|
|
2369
|
+
on_disk: false,
|
|
2370
|
+
message: `Capture ${specId} is now on deck for /hs-capture`,
|
|
2371
|
+
};
|
|
2372
|
+
}
|