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
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
writeFileSync,
|
|
43
43
|
} from 'node:fs';
|
|
44
44
|
import { createHash } from 'node:crypto';
|
|
45
|
-
import { join } from 'node:path';
|
|
45
|
+
import { join, isAbsolute, resolve, sep } from 'node:path';
|
|
46
46
|
import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
|
|
47
47
|
import { yamlScalar } from './yaml-scalar.mjs';
|
|
48
48
|
import {
|
|
@@ -72,7 +72,36 @@ import {
|
|
|
72
72
|
bumpUpdated,
|
|
73
73
|
appendChangeLogRow,
|
|
74
74
|
commitWrites,
|
|
75
|
+
fetchSpecRecordViaConsole,
|
|
76
|
+
resolveWriteModeFromDeps,
|
|
77
|
+
splitRawSpecForConsole,
|
|
78
|
+
cloudWriteFailureResult,
|
|
79
|
+
cloudModeUnsupportedWrite,
|
|
75
80
|
} from './promotion-helpers.mjs';
|
|
81
|
+
// Cloud-mode vault WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
|
|
82
|
+
// See the block comment at the head of `runExecutePhase` for the create-first,
|
|
83
|
+
// link-second reorder these three verbs make possible.
|
|
84
|
+
import {
|
|
85
|
+
createSpecViaConsole,
|
|
86
|
+
updateSpecViaConsole,
|
|
87
|
+
listSpecsViaConsole,
|
|
88
|
+
stripServerMintedFields,
|
|
89
|
+
} from './cloud-vault-writes.mjs';
|
|
90
|
+
// Cloud-tier read selector ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]) —
|
|
91
|
+
// reused verbatim, never copied. See the module doc above `fetchSpecRecordViaConsole`
|
|
92
|
+
// in promotion-helpers.mjs for why the neon branch goes through an injected
|
|
93
|
+
// consoleFetch instead of read-path.mjs's own transport.
|
|
94
|
+
import { readViaSelector } from './read-path.mjs';
|
|
95
|
+
import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
96
|
+
|
|
97
|
+
// Placeholders the CLOUD path feeds the shared content builders in place of a
|
|
98
|
+
// locally-minted id / file key / family key — stripped from the request by
|
|
99
|
+
// `stripServerMintedFields` (plus an explicit `delete` of an un-inheritable
|
|
100
|
+
// `family:`) before it ever reaches the console. Their only job is to keep the
|
|
101
|
+
// builders' templates well-formed so the BODY stored in the cloud vault is the
|
|
102
|
+
// same markdown the disk path writes.
|
|
103
|
+
const CLOUD_PENDING_ID = 'HS-PENDING';
|
|
104
|
+
const CLOUD_PENDING_KEY = 'PENDING';
|
|
76
105
|
|
|
77
106
|
/** Milestone statuses that allow attaching spawned epics without an override. */
|
|
78
107
|
export const MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES = ['open'];
|
|
@@ -177,6 +206,44 @@ function findResearchByIdLocal(vaultRoot, researchId) {
|
|
|
177
206
|
}
|
|
178
207
|
}
|
|
179
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Cloud-tier sibling of {@link findResearchByIdLocal} (HS-KR16). Same
|
|
211
|
+
* `{ record }` / error-shaped contract; the neon branch fetches the by-id
|
|
212
|
+
* spec route directly and re-derives the same `RESEARCH_NOT_FOUND` /
|
|
213
|
+
* `RESEARCH_TYPE_MISMATCH` shapes the disk branch's `resolveBreakdownSource`
|
|
214
|
+
* call produces, since that shared resolver stays disk-only and untouched.
|
|
215
|
+
* `tier`/`consoleUrl`/`consoleFetch` are resolved ONCE per handler call
|
|
216
|
+
* (`handlePromoteResearch` in research-handlers.mjs) and threaded down here.
|
|
217
|
+
*/
|
|
218
|
+
async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
|
|
219
|
+
return readViaSelector({
|
|
220
|
+
tier,
|
|
221
|
+
diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
|
|
222
|
+
neonRead: async () => {
|
|
223
|
+
const record = await fetchSpecRecordViaConsole(consoleFetch, researchId);
|
|
224
|
+
if (!record) {
|
|
225
|
+
return {
|
|
226
|
+
error: 'RESEARCH_NOT_FOUND',
|
|
227
|
+
message: `research ${researchId} not found under specs/research/ (tried exact basename, frontmatter id:, and id-prefix match)`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const fm = getFrontmatterBlock(record.raw);
|
|
231
|
+
const type = fm ? getScalarField(fm.block, 'type') : null;
|
|
232
|
+
if (type && type !== 'research') {
|
|
233
|
+
return {
|
|
234
|
+
error: 'RESEARCH_TYPE_MISMATCH',
|
|
235
|
+
message: `research_id ${researchId} resolved to ${record.relPath ?? 'a file'}, which is \`type: ${type}\` — not a research doc`,
|
|
236
|
+
resolved_basename: record.basename,
|
|
237
|
+
resolved_type: type,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { record };
|
|
241
|
+
},
|
|
242
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
243
|
+
consoleUrl,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
180
247
|
function findMilestoneByBasename(vaultRoot, basename) {
|
|
181
248
|
const absPath = join(vaultRoot, 'specs', 'milestones', `${basename}.md`);
|
|
182
249
|
if (!existsSync(absPath)) return null;
|
|
@@ -201,6 +268,34 @@ function findMilestoneById(vaultRoot, milestoneId) {
|
|
|
201
268
|
return null;
|
|
202
269
|
}
|
|
203
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Cloud-tier sibling of {@link findMilestoneByBasename} (HS-KR16). Same
|
|
273
|
+
* bare-record-or-null contract as the disk loader.
|
|
274
|
+
*/
|
|
275
|
+
async function findMilestoneByBasenameViaTier(vaultRoot, basename, { tier, consoleUrl, consoleFetch }) {
|
|
276
|
+
return readViaSelector({
|
|
277
|
+
tier,
|
|
278
|
+
diskRead: () => findMilestoneByBasename(vaultRoot, basename),
|
|
279
|
+
neonRead: () => fetchSpecRecordViaConsole(consoleFetch, basename),
|
|
280
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
281
|
+
consoleUrl,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Cloud-tier sibling of {@link findMilestoneById} (HS-KR16). Same
|
|
287
|
+
* bare-record-or-null contract as the disk loader.
|
|
288
|
+
*/
|
|
289
|
+
async function findMilestoneByIdViaTier(vaultRoot, milestoneId, { tier, consoleUrl, consoleFetch }) {
|
|
290
|
+
return readViaSelector({
|
|
291
|
+
tier,
|
|
292
|
+
diskRead: () => findMilestoneById(vaultRoot, milestoneId),
|
|
293
|
+
neonRead: () => fetchSpecRecordViaConsole(consoleFetch, milestoneId),
|
|
294
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
295
|
+
consoleUrl,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
204
299
|
function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
|
|
205
300
|
const dir = join(vaultRoot, 'specs', 'epics');
|
|
206
301
|
if (!existsSync(dir)) return null;
|
|
@@ -232,6 +327,21 @@ function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
|
|
|
232
327
|
return null;
|
|
233
328
|
}
|
|
234
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Cloud-tier sibling of {@link findEpicByIdOrBasename} (HS-KR16) — the
|
|
332
|
+
* console's by-id route accepts either an id or a basename, matching the
|
|
333
|
+
* disk loader's own two-shape contract. Same bare-record-or-null return.
|
|
334
|
+
*/
|
|
335
|
+
async function findEpicByIdOrBasenameViaTier(vaultRoot, idOrBasename, { tier, consoleUrl, consoleFetch }) {
|
|
336
|
+
return readViaSelector({
|
|
337
|
+
tier,
|
|
338
|
+
diskRead: () => findEpicByIdOrBasename(vaultRoot, idOrBasename),
|
|
339
|
+
neonRead: () => fetchSpecRecordViaConsole(consoleFetch, idOrBasename),
|
|
340
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
341
|
+
consoleUrl,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
235
345
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
346
|
// Pillar resolution (epic→pillar hard requirement, [[pillar-assignment-protocol]])
|
|
237
347
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -295,6 +405,50 @@ export function resolvePillar(vaultRoot, raw) {
|
|
|
295
405
|
return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
|
|
296
406
|
}
|
|
297
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Cloud sibling of {@link listVaultPillars} ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]] AC3).
|
|
410
|
+
*
|
|
411
|
+
* The disk version is a `readdirSync(specs/pillars)` scan, which in a file-less
|
|
412
|
+
* vault returns `[]` — so a `pillar_required` refusal would list NO valid
|
|
413
|
+
* options and read as "there are no pillars" rather than "this machine holds
|
|
414
|
+
* no copy of them". `GET /api/specs?type=pillar` (`listSpecsViaConsole`, which
|
|
415
|
+
* follows the keyset cursor) is the route that answers the same question in
|
|
416
|
+
* cloud mode. Fails SOFT to `[]`, exactly like the disk version: this list only
|
|
417
|
+
* enriches an error message that is being returned either way, and turning a
|
|
418
|
+
* refusal into an unreachable-console error would hide the real cause.
|
|
419
|
+
*/
|
|
420
|
+
export async function listVaultPillarsViaWriteMode(vaultRoot, { writeMode, consoleFetch, consoleUrl }) {
|
|
421
|
+
if (writeMode !== 'cloud') return listVaultPillars(vaultRoot);
|
|
422
|
+
try {
|
|
423
|
+
const rows = await listSpecsViaConsole({ consoleFetch, consoleUrl, type: 'pillar' });
|
|
424
|
+
return rows.map((row) => ({
|
|
425
|
+
id: row.basename ?? row.id ?? null,
|
|
426
|
+
name: row.name ?? row.basename ?? row.id ?? null,
|
|
427
|
+
}));
|
|
428
|
+
} catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Cloud-tier sibling of {@link resolvePillar} (HS-KR16).
|
|
435
|
+
*/
|
|
436
|
+
async function resolvePillarViaTier(vaultRoot, raw, { tier, consoleUrl, consoleFetch }) {
|
|
437
|
+
const basename = normalizePillarRef(raw);
|
|
438
|
+
if (!basename) return { ok: false };
|
|
439
|
+
return readViaSelector({
|
|
440
|
+
tier,
|
|
441
|
+
diskRead: () => resolvePillar(vaultRoot, raw),
|
|
442
|
+
neonRead: async () => {
|
|
443
|
+
const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
|
|
444
|
+
if (!record) return { ok: false };
|
|
445
|
+
return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
|
|
446
|
+
},
|
|
447
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
448
|
+
consoleUrl,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
298
452
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
453
|
// AI proposer wiring
|
|
300
454
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -495,21 +649,32 @@ function validateResearchState(research) {
|
|
|
495
649
|
// validateBidirectionalEnhances (RESEARCH_ALREADY_PROMOTED).
|
|
496
650
|
const status = getScalarField(fm.block, 'status');
|
|
497
651
|
const enhances = getScalarField(fm.block, 'enhances');
|
|
498
|
-
|
|
652
|
+
// [[HS-8XJA-KOGH-DIAG-passing-enhances-to-heyspex-promote]]: `enhances` is
|
|
653
|
+
// ALSO a heyspex_promote_capture argument that writes this same field one
|
|
654
|
+
// stage earlier, before any epic has actually been spawned — forging the
|
|
655
|
+
// "already promoted" signal ahead of the event it's supposed to record. A
|
|
656
|
+
// GENUINE promote-research execution always writes `spawned_epics` and/or
|
|
657
|
+
// `enhances_epics` in the SAME commit as `enhances` (buildUpdatedResearchContent
|
|
658
|
+
// below) — so `enhances` set with BOTH of those empty can only mean it was
|
|
659
|
+
// pre-set at capture-promote time, never backed by a real promotion.
|
|
660
|
+
const spawnedEpics = parseListFieldBasenames(fm.block, 'spawned_epics') ?? [];
|
|
661
|
+
const enhancesEpics = parseListFieldBasenames(fm.block, 'enhances_epics') ?? [];
|
|
662
|
+
const enhancesBacked = spawnedEpics.length > 0 || enhancesEpics.length > 0;
|
|
663
|
+
return { fm, status, enhances, enhancesBacked };
|
|
499
664
|
}
|
|
500
665
|
|
|
501
|
-
function resolveTargetMilestone(vaultRoot, args,
|
|
666
|
+
async function resolveTargetMilestone(vaultRoot, args, { tier, consoleUrl, consoleFetch }) {
|
|
502
667
|
if (args.target_milestone === 'NEW') {
|
|
503
668
|
return { mode: 'new', name: args.new_milestone_name.trim() };
|
|
504
669
|
}
|
|
505
670
|
// Accept either bare HS-MIL-XXXX, the full basename, or wikilink wrapping.
|
|
506
671
|
const basenameCandidate = normalizeMilestoneBasename(args.target_milestone);
|
|
507
672
|
if (basenameCandidate) {
|
|
508
|
-
const ms =
|
|
673
|
+
const ms = await findMilestoneByBasenameViaTier(vaultRoot, basenameCandidate, { tier, consoleUrl, consoleFetch });
|
|
509
674
|
if (ms) return { mode: 'existing', milestone: ms };
|
|
510
675
|
}
|
|
511
676
|
if (parseSpecId(args.target_milestone)?.typeLabel === 'MIL') {
|
|
512
|
-
const ms =
|
|
677
|
+
const ms = await findMilestoneByIdViaTier(vaultRoot, args.target_milestone, { tier, consoleUrl, consoleFetch });
|
|
513
678
|
if (ms) return { mode: 'existing', milestone: ms };
|
|
514
679
|
}
|
|
515
680
|
return {
|
|
@@ -652,6 +817,7 @@ async function runProposalPhase(args, ctx, deps) {
|
|
|
652
817
|
/** Minimum length for an explanation, matching attach_override_reason above. */
|
|
653
818
|
const EDIT_NOTE_MIN_LENGTH = 40;
|
|
654
819
|
|
|
820
|
+
|
|
655
821
|
/** Marker path for a research doc's proposal turn. Keyed on the research id — the
|
|
656
822
|
* one value stable across both turns, since the human is expected to EDIT the
|
|
657
823
|
* stubs in between (so hashing the stub content, as the plan gate does, would
|
|
@@ -672,6 +838,7 @@ function normalizeStubName(name) {
|
|
|
672
838
|
export function writeStubReviewMarker(vaultRoot, researchId, proposal = {}) {
|
|
673
839
|
try {
|
|
674
840
|
const file = stubReviewMarkerPath(vaultRoot, researchId);
|
|
841
|
+
assertPathUnderRoot(vaultRoot, join(file, '..'), 'promote-research write');
|
|
675
842
|
mkdirSync(join(file, '..'), { recursive: true });
|
|
676
843
|
writeFileSync(
|
|
677
844
|
file,
|
|
@@ -815,6 +982,61 @@ function resolveOwnerIntent(vaultRoot, researchRaw) {
|
|
|
815
982
|
}
|
|
816
983
|
}
|
|
817
984
|
|
|
985
|
+
/**
|
|
986
|
+
* Cloud-tier sibling of {@link resolveOwnerIntent} (HS-KR16) — same
|
|
987
|
+
* best-effort, never-throws contract (a missing/unreachable source capture
|
|
988
|
+
* just means no owner-intent enrichment, not a hard failure). Only the
|
|
989
|
+
* per-basename capture read moves through the tier selector; the
|
|
990
|
+
* sources_captures/related parsing stays identical to the disk version.
|
|
991
|
+
*/
|
|
992
|
+
async function resolveOwnerIntentViaTier(vaultRoot, researchRaw, { tier, consoleUrl, consoleFetch }) {
|
|
993
|
+
try {
|
|
994
|
+
const fm = getFrontmatterBlock(researchRaw);
|
|
995
|
+
if (!fm) return null;
|
|
996
|
+
let basenames = parseListFieldBasenames(fm.block, 'sources_captures') ?? [];
|
|
997
|
+
if (basenames.length === 0) {
|
|
998
|
+
basenames = (parseListFieldBasenames(fm.block, 'related') ?? []).filter((b) =>
|
|
999
|
+
/(^|-)CAP(-|$)/i.test(b),
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (basenames.length === 0) return null;
|
|
1003
|
+
for (const basename of basenames) {
|
|
1004
|
+
let raw;
|
|
1005
|
+
try {
|
|
1006
|
+
raw = await readViaSelector({
|
|
1007
|
+
tier,
|
|
1008
|
+
diskRead: () => {
|
|
1009
|
+
const capturesDir = join(vaultRoot, 'specs', 'captures');
|
|
1010
|
+
let abs = join(capturesDir, `${basename}.md`);
|
|
1011
|
+
if (!existsSync(abs)) {
|
|
1012
|
+
const byPrefix = findFileByPrefix(capturesDir, basename);
|
|
1013
|
+
if (!byPrefix) return null;
|
|
1014
|
+
abs = join(capturesDir, byPrefix);
|
|
1015
|
+
}
|
|
1016
|
+
return readFileSync(abs, 'utf8');
|
|
1017
|
+
},
|
|
1018
|
+
neonRead: async () => {
|
|
1019
|
+
const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
|
|
1020
|
+
return record ? record.raw : null;
|
|
1021
|
+
},
|
|
1022
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
1023
|
+
consoleUrl,
|
|
1024
|
+
});
|
|
1025
|
+
} catch {
|
|
1026
|
+
continue; // a genuine console failure here is soft — skip, try the next basename.
|
|
1027
|
+
}
|
|
1028
|
+
if (!raw) continue;
|
|
1029
|
+
const body = getFrontmatterBlock(raw)?.body ?? raw;
|
|
1030
|
+
const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
|
|
1031
|
+
const coreIdea = m ? m[1].trim() : '';
|
|
1032
|
+
if (coreIdea) return { basename, coreIdea };
|
|
1033
|
+
}
|
|
1034
|
+
return null;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
818
1040
|
/**
|
|
819
1041
|
* Pull the research doc's own `## JTBD (job-to-be-done)` section body, so
|
|
820
1042
|
* spawned milestone/epic stubs can re-derive their altitude's slice of the
|
|
@@ -1018,6 +1240,38 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1018
1240
|
const now = (deps?.clock ?? isoNow)();
|
|
1019
1241
|
const today = now.slice(0, 10);
|
|
1020
1242
|
const author = 'promote-research (agent)';
|
|
1243
|
+
// Same already-resolved tier this call's executePromoteResearch entry point
|
|
1244
|
+
// read from deps.tier ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]])
|
|
1245
|
+
// — read again here, never re-resolved via resolveReadTier().
|
|
1246
|
+
const tierDeps = { tier: deps?.tier ?? 'disk', consoleUrl: deps?.consoleUrl, consoleFetch: deps?.consoleFetch };
|
|
1247
|
+
// ── Vault WRITE mode ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]) ──
|
|
1248
|
+
//
|
|
1249
|
+
// Resolved ONCE by the caller (`resolveVaultWriteMode()`) and threaded in on
|
|
1250
|
+
// `deps`; absent — every pre-existing caller and test — it is `local`, so the
|
|
1251
|
+
// disk plan-then-`commitWrites` path below is byte-identical to before.
|
|
1252
|
+
//
|
|
1253
|
+
// CREATE FIRST, LINK SECOND. On disk this flow mints every filename itself,
|
|
1254
|
+
// so it can stage all four write groups and commit them as one transaction.
|
|
1255
|
+
// In cloud mode the console mints the id, the family and the filename, and
|
|
1256
|
+
// the dependencies run strictly one way:
|
|
1257
|
+
//
|
|
1258
|
+
// milestone ← links to: research basename (known up front)
|
|
1259
|
+
// epic ← links to: MILESTONE basename (only after its POST)
|
|
1260
|
+
// epic ← links to: SIBLING epic basenames (only after their POSTs)
|
|
1261
|
+
// milestone ← links to: EPIC basenames (`epics:`) (only after their POSTs)
|
|
1262
|
+
// research ← links to: MILESTONE + EPIC basenames (only after their POSTs)
|
|
1263
|
+
//
|
|
1264
|
+
// so the cloud order is: POST milestone → POST every epic (with only the
|
|
1265
|
+
// deps already resolvable) → PUT epics that depend on a sibling → PUT the
|
|
1266
|
+
// milestone's `epics:` mirror → PUT the research doc. Every wikilink written
|
|
1267
|
+
// in a PUT body therefore names a basename a POST actually returned.
|
|
1268
|
+
const writeMode = resolveWriteModeFromDeps(deps);
|
|
1269
|
+
const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
|
|
1270
|
+
const cloudCompleted = [];
|
|
1271
|
+
const recordCloud = (entry) => {
|
|
1272
|
+
cloudCompleted.push(entry);
|
|
1273
|
+
return entry;
|
|
1274
|
+
};
|
|
1021
1275
|
|
|
1022
1276
|
// Family inheritance ([[HS-GZM9-DIAG-G419]], mirrors promote-capture.mjs /
|
|
1023
1277
|
// split-research.mjs): the spawned milestone AND every spawned epic inherit
|
|
@@ -1041,23 +1295,39 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1041
1295
|
let existingMilestoneEpics = [];
|
|
1042
1296
|
|
|
1043
1297
|
if (ctx.targetResolution.mode === 'new') {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
inheritedFamilyKey ??
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1298
|
+
if (writeMode === 'cloud') {
|
|
1299
|
+
// NOTHING is minted locally in cloud mode (AC3): `allocateFileKey`,
|
|
1300
|
+
// `collectOccupiedKeysEitherKind` and `allocateMilestoneId` all derive
|
|
1301
|
+
// their answer from a vault SCAN, and in a file-less vault that scan is
|
|
1302
|
+
// empty — a key drawn against an empty occupied set can collide with one
|
|
1303
|
+
// the cloud vault already holds. `createSpec` mints the file key, the
|
|
1304
|
+
// family and the filename server-side. An INHERITED family is still
|
|
1305
|
+
// passed through: it is read off the source research doc, not minted.
|
|
1306
|
+
milestoneFileKey = null;
|
|
1307
|
+
milestoneFamilyKey = inheritedFamilyKey ?? null;
|
|
1308
|
+
milestoneId = null;
|
|
1309
|
+
milestoneBasename = null;
|
|
1310
|
+
milestoneAbsPath = null;
|
|
1311
|
+
milestoneRelPath = null;
|
|
1312
|
+
} else {
|
|
1313
|
+
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
1314
|
+
// and (V3) the anchor for `id:` itself.
|
|
1315
|
+
milestoneFileKey = allocateFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), {
|
|
1316
|
+
randomUnit: deps?.randomUnit,
|
|
1317
|
+
});
|
|
1318
|
+
// Family: inherit from the research doc; the legacy allocator's
|
|
1319
|
+
// `HS-<family>-MIL` draw is only the fallback family source (never blank —
|
|
1320
|
+
// last resort is the fresh file key itself, matching the old chain).
|
|
1321
|
+
milestoneFamilyKey =
|
|
1322
|
+
inheritedFamilyKey ??
|
|
1323
|
+
parseSpecId(allocateMilestoneId(ctx.vaultRoot, { randomUnit: deps?.randomUnit }))?.familyKey ??
|
|
1324
|
+
milestoneFileKey;
|
|
1325
|
+
milestoneId = `HS-${milestoneFileKey}`;
|
|
1326
|
+
const slug = deriveSlug(ctx.targetResolution.name);
|
|
1327
|
+
milestoneBasename = `${milestoneId}-${milestoneFamilyKey}-MIL-${slug}`;
|
|
1328
|
+
milestoneAbsPath = join(ctx.vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
|
|
1329
|
+
milestoneRelPath = join('specs', 'milestones', `${milestoneBasename}.md`);
|
|
1330
|
+
}
|
|
1061
1331
|
milestoneCreated = true;
|
|
1062
1332
|
} else {
|
|
1063
1333
|
const ms = ctx.targetResolution.milestone;
|
|
@@ -1076,7 +1346,7 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1076
1346
|
const augmentEpics = [];
|
|
1077
1347
|
if (args.intent === 'augment' || args.intent === 'both') {
|
|
1078
1348
|
for (const epicRef of args.augment_epic_ids) {
|
|
1079
|
-
const found =
|
|
1349
|
+
const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, epicRef, tierDeps);
|
|
1080
1350
|
if (!found) {
|
|
1081
1351
|
return {
|
|
1082
1352
|
error: 'AUGMENT_EPIC_NOT_FOUND',
|
|
@@ -1106,10 +1376,14 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1106
1376
|
|
|
1107
1377
|
const spawnedEpicSpecs = [];
|
|
1108
1378
|
if (acceptedStubs.length > 0) {
|
|
1109
|
-
|
|
1379
|
+
// Both occupied sets are DISK SCANS — skipped entirely in cloud mode,
|
|
1380
|
+
// where no key is minted client-side (AC3).
|
|
1381
|
+
const { epics: occupiedEpics } =
|
|
1382
|
+
writeMode === 'cloud' ? { epics: new Set() } : collectOccupiedVaultIds(ctx.vaultRoot);
|
|
1110
1383
|
// One occupied set across the whole loop — files are not on disk yet, so a
|
|
1111
1384
|
// fresh vault walk per stub would let two stubs draw the same file key.
|
|
1112
|
-
const occupiedFileKeys =
|
|
1385
|
+
const occupiedFileKeys =
|
|
1386
|
+
writeMode === 'cloud' ? new Set() : collectOccupiedKeysEitherKind(ctx.vaultRoot); // union: file+family keys (HS-SJOT)
|
|
1113
1387
|
for (const stub of acceptedStubs) {
|
|
1114
1388
|
if (!stub?.name?.trim()) {
|
|
1115
1389
|
return {
|
|
@@ -1117,23 +1391,32 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1117
1391
|
message: 'every accepted stub must have a non-empty name',
|
|
1118
1392
|
};
|
|
1119
1393
|
}
|
|
1120
|
-
|
|
1121
|
-
// and (V3) the anchor for `id:` itself.
|
|
1122
|
-
const fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
|
|
1123
|
-
occupiedFileKeys.add(fileKey);
|
|
1124
|
-
// Family: inherit from the research doc; the legacy allocator's
|
|
1125
|
-
// `HS-<family>-EPIC` draw is only the fallback family source.
|
|
1394
|
+
let fileKey = null;
|
|
1126
1395
|
let epicFamilyKey = inheritedFamilyKey;
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1396
|
+
let epicId = null;
|
|
1397
|
+
let basename = null;
|
|
1398
|
+
if (writeMode === 'cloud') {
|
|
1399
|
+
// Basename/id stay null until this stub's own POST answers; the
|
|
1400
|
+
// inherited family (read off the research doc, never minted) is the
|
|
1401
|
+
// only naming input that survives into the request.
|
|
1402
|
+
epicFamilyKey = inheritedFamilyKey ?? null;
|
|
1403
|
+
} else {
|
|
1404
|
+
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
1405
|
+
// and (V3) the anchor for `id:` itself.
|
|
1406
|
+
fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
|
|
1407
|
+
occupiedFileKeys.add(fileKey);
|
|
1408
|
+
// Family: inherit from the research doc; the legacy allocator's
|
|
1409
|
+
// `HS-<family>-EPIC` draw is only the fallback family source.
|
|
1410
|
+
if (!epicFamilyKey) {
|
|
1411
|
+
const allocatedEpicFamilyId = allocateRandomVaultId('epic', occupiedEpics, {
|
|
1412
|
+
randomUnit: deps?.randomUnit,
|
|
1413
|
+
});
|
|
1414
|
+
occupiedEpics.add(allocatedEpicFamilyId);
|
|
1415
|
+
epicFamilyKey = parseSpecId(allocatedEpicFamilyId)?.familyKey ?? fileKey;
|
|
1416
|
+
}
|
|
1417
|
+
epicId = `HS-${fileKey}`;
|
|
1418
|
+
basename = `${epicId}-${epicFamilyKey}-EPIC-${deriveSlug(stub.name)}`;
|
|
1133
1419
|
}
|
|
1134
|
-
const epicId = `HS-${fileKey}`;
|
|
1135
|
-
const slug = deriveSlug(stub.name);
|
|
1136
|
-
const basename = `${epicId}-${epicFamilyKey}-EPIC-${slug}`;
|
|
1137
1420
|
spawnedEpicSpecs.push({
|
|
1138
1421
|
id: epicId,
|
|
1139
1422
|
fileKey,
|
|
@@ -1147,8 +1430,8 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1147
1430
|
// Per-stub pillar override; falls back to the batch-default `args.pillar`
|
|
1148
1431
|
// in the gate below (stub value wins — [[pillar-assignment-protocol]]).
|
|
1149
1432
|
pillar: typeof stub.pillar === 'string' ? stub.pillar : undefined,
|
|
1150
|
-
relPath: join('specs', 'epics', `${basename}.md`),
|
|
1151
|
-
absPath: join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`),
|
|
1433
|
+
relPath: basename ? join('specs', 'epics', `${basename}.md`) : null,
|
|
1434
|
+
absPath: basename ? join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`) : null,
|
|
1152
1435
|
});
|
|
1153
1436
|
}
|
|
1154
1437
|
}
|
|
@@ -1161,20 +1444,32 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1161
1444
|
// each entry that matches a sibling stub's `name` with that sibling's
|
|
1162
1445
|
// freshly-minted basename; else resolve against an existing vault epic by
|
|
1163
1446
|
// id/basename; else DROP it (warn) rather than write an unresolvable link.
|
|
1447
|
+
//
|
|
1448
|
+
// In CLOUD mode a sibling's basename does not exist yet either (the
|
|
1449
|
+
// console mints it at POST time), so the sibling half of this resolution
|
|
1450
|
+
// is DEFERRED: the entry is parked on `spec.pendingSiblingDeps` and folded
|
|
1451
|
+
// in by a follow-up PUT once every sibling POST has answered. The
|
|
1452
|
+
// externally-resolved half is identical in both modes.
|
|
1164
1453
|
const depWarnings = [];
|
|
1165
1454
|
if (spawnedEpicSpecs.length > 0) {
|
|
1455
|
+
const siblingNames = new Set(spawnedEpicSpecs.map((s) => s.name.trim().toLowerCase()));
|
|
1166
1456
|
const siblingNameToBasename = new Map();
|
|
1167
1457
|
for (const s of spawnedEpicSpecs) {
|
|
1168
1458
|
siblingNameToBasename.set(s.name.trim().toLowerCase(), s.basename);
|
|
1169
1459
|
}
|
|
1170
1460
|
for (const spec of spawnedEpicSpecs) {
|
|
1171
1461
|
const resolved = [];
|
|
1462
|
+
const pendingSiblings = [];
|
|
1172
1463
|
for (const raw of spec.depends_on) {
|
|
1173
1464
|
const d = typeof raw === 'string' ? raw.trim() : '';
|
|
1174
1465
|
if (!d) continue;
|
|
1466
|
+
if (writeMode === 'cloud' && siblingNames.has(d.toLowerCase())) {
|
|
1467
|
+
pendingSiblings.push(d.toLowerCase());
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1175
1470
|
let target = siblingNameToBasename.get(d.toLowerCase());
|
|
1176
1471
|
if (!target) {
|
|
1177
|
-
const found =
|
|
1472
|
+
const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, d, tierDeps);
|
|
1178
1473
|
if (found) target = found.basename;
|
|
1179
1474
|
}
|
|
1180
1475
|
if (target) {
|
|
@@ -1184,6 +1479,7 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1184
1479
|
}
|
|
1185
1480
|
}
|
|
1186
1481
|
spec.depends_on = resolved;
|
|
1482
|
+
spec.pendingSiblingDeps = pendingSiblings;
|
|
1187
1483
|
}
|
|
1188
1484
|
}
|
|
1189
1485
|
|
|
@@ -1195,14 +1491,19 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1195
1491
|
if (spawnedEpicSpecs.length > 0) {
|
|
1196
1492
|
for (const spec of spawnedEpicSpecs) {
|
|
1197
1493
|
const pillarRef = spec.pillar ?? args.pillar;
|
|
1198
|
-
const resolved =
|
|
1494
|
+
const resolved = await resolvePillarViaTier(ctx.vaultRoot, pillarRef, tierDeps);
|
|
1199
1495
|
if (!resolved.ok) {
|
|
1200
1496
|
return {
|
|
1201
1497
|
error: 'pillar_required',
|
|
1202
1498
|
message: pillarRef
|
|
1203
1499
|
? `epic "${spec.name}" pillar \`${pillarRef}\` does not resolve to a pillar under specs/pillars/ — pick an existing pillar or author a new one first`
|
|
1204
1500
|
: `epic "${spec.name}" requires a \`pillar:\` wikilink to an HS-PILR theme — pass a top-level \`pillar\` batch default or a per-stub \`pillar\`; pick an existing pillar or author a new one first`,
|
|
1205
|
-
|
|
1501
|
+
// AC3: in cloud mode this list comes from `GET /api/specs?type=pillar`,
|
|
1502
|
+
// not from a `readdirSync(specs/pillars)` that would be empty here.
|
|
1503
|
+
existing_pillars: await listVaultPillarsViaWriteMode(ctx.vaultRoot, {
|
|
1504
|
+
writeMode,
|
|
1505
|
+
...cloudDeps,
|
|
1506
|
+
}),
|
|
1206
1507
|
create_pillar: {
|
|
1207
1508
|
tool: 'heyspex_file_spec',
|
|
1208
1509
|
call: { type: 'pillar', name: '<theme name>' },
|
|
@@ -1220,11 +1521,295 @@ async function runExecutePhase(args, ctx, deps) {
|
|
|
1220
1521
|
// The owner's own words from the originating capture, carried onto every spec
|
|
1221
1522
|
// this promotion creates so intent survives the hop into the plan graph.
|
|
1222
1523
|
// Null when no capture resolves — stubs then render exactly as before.
|
|
1223
|
-
const ownerIntent =
|
|
1524
|
+
const ownerIntent = await resolveOwnerIntentViaTier(ctx.vaultRoot, ctx.research.raw, tierDeps);
|
|
1224
1525
|
// HS-M1MW: seed spawned stubs' JTBD sections from the research doc's own
|
|
1225
1526
|
// JTBD section, when present.
|
|
1226
1527
|
const researchJtbd = extractResearchJtbd(ctx.research.raw);
|
|
1227
1528
|
|
|
1529
|
+
// ── CLOUD PATH ────────────────────────────────────────────────────────────
|
|
1530
|
+
// Returns early; everything below this block is the untouched disk
|
|
1531
|
+
// plan-then-`commitWrites` path.
|
|
1532
|
+
if (writeMode === 'cloud') {
|
|
1533
|
+
/** Frontmatter/body the console actually stored, falling back to ours. */
|
|
1534
|
+
const storedOf = (created, fallbackRaw) => {
|
|
1535
|
+
const fallback = splitRawSpecForConsole(fallbackRaw);
|
|
1536
|
+
return {
|
|
1537
|
+
frontmatter: created?.spec?.frontmatter ?? fallback.frontmatter,
|
|
1538
|
+
body: typeof created?.spec?.body === 'string' ? created.spec.body : fallback.body,
|
|
1539
|
+
};
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
// ── 1. POST the milestone FIRST — every epic's `parent:`/`milestone:` and
|
|
1543
|
+
// the research's `enhances:` name the basename it returns.
|
|
1544
|
+
let milestoneStored = null;
|
|
1545
|
+
if (milestoneCreated) {
|
|
1546
|
+
const newContent = buildMilestoneStubContent(
|
|
1547
|
+
CLOUD_PENDING_ID,
|
|
1548
|
+
CLOUD_PENDING_KEY,
|
|
1549
|
+
milestoneFamilyKey ?? CLOUD_PENDING_KEY,
|
|
1550
|
+
ctx.targetResolution.name,
|
|
1551
|
+
ctx.research.basename,
|
|
1552
|
+
now,
|
|
1553
|
+
ownerIntent,
|
|
1554
|
+
researchJtbd,
|
|
1555
|
+
);
|
|
1556
|
+
const { frontmatter, body } = splitRawSpecForConsole(newContent);
|
|
1557
|
+
const cleaned = stripServerMintedFields(frontmatter);
|
|
1558
|
+
if (!milestoneFamilyKey) delete cleaned.family;
|
|
1559
|
+
let created;
|
|
1560
|
+
try {
|
|
1561
|
+
created = await createSpecViaConsole({
|
|
1562
|
+
...cloudDeps,
|
|
1563
|
+
type: 'milestone',
|
|
1564
|
+
name: ctx.targetResolution.name,
|
|
1565
|
+
frontmatter: cleaned,
|
|
1566
|
+
body,
|
|
1567
|
+
actingAgent: deps?.actingAgent ?? null,
|
|
1568
|
+
});
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
return cloudWriteFailureResult(err, {
|
|
1571
|
+
step: { kind: 'create', type: 'milestone', name: ctx.targetResolution.name },
|
|
1572
|
+
completed: cloudCompleted,
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
milestoneId = created.specId;
|
|
1576
|
+
milestoneBasename = created.basename;
|
|
1577
|
+
milestoneRelPath = created.relPath;
|
|
1578
|
+
milestoneStored = storedOf(created, newContent);
|
|
1579
|
+
recordCloud({
|
|
1580
|
+
kind: 'create',
|
|
1581
|
+
type: 'milestone',
|
|
1582
|
+
spec_id: created.specId,
|
|
1583
|
+
basename: created.basename,
|
|
1584
|
+
rel_path: created.relPath,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// ── 2. POST every epic, now that the milestone basename is real.
|
|
1589
|
+
for (const spec of spawnedEpicSpecs) {
|
|
1590
|
+
const content = buildEpicContent(
|
|
1591
|
+
{
|
|
1592
|
+
...spec,
|
|
1593
|
+
id: CLOUD_PENDING_ID,
|
|
1594
|
+
fileKey: CLOUD_PENDING_KEY,
|
|
1595
|
+
familyKey: spec.familyKey ?? CLOUD_PENDING_KEY,
|
|
1596
|
+
},
|
|
1597
|
+
milestoneBasename,
|
|
1598
|
+
ctx.research.basename,
|
|
1599
|
+
now,
|
|
1600
|
+
ownerIntent,
|
|
1601
|
+
researchJtbd,
|
|
1602
|
+
);
|
|
1603
|
+
const { frontmatter, body } = splitRawSpecForConsole(content);
|
|
1604
|
+
const cleaned = stripServerMintedFields(frontmatter);
|
|
1605
|
+
if (!spec.familyKey) delete cleaned.family;
|
|
1606
|
+
let created;
|
|
1607
|
+
try {
|
|
1608
|
+
created = await createSpecViaConsole({
|
|
1609
|
+
...cloudDeps,
|
|
1610
|
+
type: 'epic',
|
|
1611
|
+
name: spec.name,
|
|
1612
|
+
frontmatter: cleaned,
|
|
1613
|
+
body,
|
|
1614
|
+
actingAgent: deps?.actingAgent ?? null,
|
|
1615
|
+
});
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
return cloudWriteFailureResult(err, {
|
|
1618
|
+
step: { kind: 'create', type: 'epic', name: spec.name },
|
|
1619
|
+
completed: cloudCompleted,
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
spec.id = created.specId;
|
|
1623
|
+
spec.basename = created.basename;
|
|
1624
|
+
spec.relPath = created.relPath;
|
|
1625
|
+
spec.cloudStored = storedOf(created, content);
|
|
1626
|
+
recordCloud({
|
|
1627
|
+
kind: 'create',
|
|
1628
|
+
type: 'epic',
|
|
1629
|
+
spec_id: created.specId,
|
|
1630
|
+
basename: created.basename,
|
|
1631
|
+
rel_path: created.relPath,
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ── 3. PUT the epics whose `depends_on` names a SIBLING: that basename
|
|
1636
|
+
// only became knowable in step 2.
|
|
1637
|
+
const siblingBasenameByName = new Map(
|
|
1638
|
+
spawnedEpicSpecs.map((s) => [s.name.trim().toLowerCase(), s.basename]),
|
|
1639
|
+
);
|
|
1640
|
+
for (const spec of spawnedEpicSpecs) {
|
|
1641
|
+
if (!spec.pendingSiblingDeps?.length) continue;
|
|
1642
|
+
const siblingBasenames = [];
|
|
1643
|
+
for (const name of spec.pendingSiblingDeps) {
|
|
1644
|
+
const b = siblingBasenameByName.get(name);
|
|
1645
|
+
if (b) siblingBasenames.push(b);
|
|
1646
|
+
else depWarnings.push({ epic: spec.name, unresolved: name });
|
|
1647
|
+
}
|
|
1648
|
+
if (siblingBasenames.length === 0) continue;
|
|
1649
|
+
spec.depends_on = [...spec.depends_on, ...siblingBasenames];
|
|
1650
|
+
const fm = { ...spec.cloudStored.frontmatter };
|
|
1651
|
+
fm.depends_on = spec.depends_on.map((d) => `[[${d}]]`);
|
|
1652
|
+
try {
|
|
1653
|
+
const res = await updateSpecViaConsole({
|
|
1654
|
+
...cloudDeps,
|
|
1655
|
+
specId: spec.id,
|
|
1656
|
+
frontmatter: fm,
|
|
1657
|
+
body: spec.cloudStored.body,
|
|
1658
|
+
});
|
|
1659
|
+
recordCloud({
|
|
1660
|
+
kind: 'update',
|
|
1661
|
+
type: 'epic.depends_on',
|
|
1662
|
+
spec_id: res.specId,
|
|
1663
|
+
basename: res.basename ?? spec.basename,
|
|
1664
|
+
rel_path: res.relPath,
|
|
1665
|
+
});
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
return cloudWriteFailureResult(err, {
|
|
1668
|
+
step: { kind: 'update', type: 'epic.depends_on', basename: spec.basename },
|
|
1669
|
+
completed: cloudCompleted,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ── 4. PUT the milestone's `epics:` mirror (the cloud twin of the
|
|
1675
|
+
// post-commit `appendEpicToMilestone` line surgery) + `informed_by:`
|
|
1676
|
+
// on an EXISTING milestone. Both reference basenames from step 2.
|
|
1677
|
+
const spawnedBasenames = spawnedEpicSpecs.map((s) => s.basename);
|
|
1678
|
+
if (milestoneCreated) {
|
|
1679
|
+
if (spawnedBasenames.length > 0) {
|
|
1680
|
+
const fm = { ...milestoneStored.frontmatter };
|
|
1681
|
+
fm.epics = spawnedBasenames.map((b) => `[[${b}]]`);
|
|
1682
|
+
try {
|
|
1683
|
+
const res = await updateSpecViaConsole({
|
|
1684
|
+
...cloudDeps,
|
|
1685
|
+
specId: milestoneId,
|
|
1686
|
+
frontmatter: fm,
|
|
1687
|
+
body: milestoneStored.body,
|
|
1688
|
+
});
|
|
1689
|
+
recordCloud({
|
|
1690
|
+
kind: 'update',
|
|
1691
|
+
type: 'milestone.epics',
|
|
1692
|
+
spec_id: res.specId,
|
|
1693
|
+
basename: res.basename ?? milestoneBasename,
|
|
1694
|
+
rel_path: res.relPath,
|
|
1695
|
+
});
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
return cloudWriteFailureResult(err, {
|
|
1698
|
+
step: { kind: 'update', type: 'milestone.epics', basename: milestoneBasename },
|
|
1699
|
+
completed: cloudCompleted,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
} else {
|
|
1704
|
+
const msRaw = ctx.targetResolution.milestone.raw;
|
|
1705
|
+
let updatedRaw = buildUpdatedMilestoneContent(msRaw, {
|
|
1706
|
+
researchBasename: ctx.research.basename,
|
|
1707
|
+
now,
|
|
1708
|
+
});
|
|
1709
|
+
const fmBlock = getFrontmatterBlock(updatedRaw);
|
|
1710
|
+
if (fmBlock && spawnedBasenames.length > 0) {
|
|
1711
|
+
let block = fmBlock.block;
|
|
1712
|
+
for (const b of spawnedBasenames) block = appendToListField(block, 'epics', b).block;
|
|
1713
|
+
updatedRaw = `${fmBlock.open}${block}${fmBlock.close}${fmBlock.body}`;
|
|
1714
|
+
}
|
|
1715
|
+
if (updatedRaw !== msRaw) {
|
|
1716
|
+
const { frontmatter, body } = splitRawSpecForConsole(updatedRaw);
|
|
1717
|
+
try {
|
|
1718
|
+
const res = await updateSpecViaConsole({
|
|
1719
|
+
...cloudDeps,
|
|
1720
|
+
specId: milestoneId,
|
|
1721
|
+
frontmatter,
|
|
1722
|
+
body,
|
|
1723
|
+
});
|
|
1724
|
+
recordCloud({
|
|
1725
|
+
kind: 'update',
|
|
1726
|
+
type: 'milestone',
|
|
1727
|
+
spec_id: res.specId,
|
|
1728
|
+
basename: res.basename ?? milestoneBasename,
|
|
1729
|
+
rel_path: res.relPath,
|
|
1730
|
+
});
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
return cloudWriteFailureResult(err, {
|
|
1733
|
+
step: { kind: 'update', type: 'milestone', basename: milestoneBasename },
|
|
1734
|
+
completed: cloudCompleted,
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ── 5. PUT the research doc LAST: `enhances:` + `spawned_epics:` +
|
|
1741
|
+
// `enhances_epics:` all name basenames a POST returned above.
|
|
1742
|
+
const researchContentCloud = buildUpdatedResearchContent(ctx.research.raw, {
|
|
1743
|
+
milestoneBasename,
|
|
1744
|
+
spawnedEpicBasenames: spawnedBasenames,
|
|
1745
|
+
augmentedEpicBasenames: augmentEpics.map((e) => e.basename),
|
|
1746
|
+
now,
|
|
1747
|
+
today,
|
|
1748
|
+
author,
|
|
1749
|
+
});
|
|
1750
|
+
{
|
|
1751
|
+
const { frontmatter, body } = splitRawSpecForConsole(researchContentCloud);
|
|
1752
|
+
try {
|
|
1753
|
+
const res = await updateSpecViaConsole({
|
|
1754
|
+
...cloudDeps,
|
|
1755
|
+
specId: ctx.research.frontmatter?.id ?? args.research_id,
|
|
1756
|
+
frontmatter,
|
|
1757
|
+
body,
|
|
1758
|
+
});
|
|
1759
|
+
recordCloud({
|
|
1760
|
+
kind: 'update',
|
|
1761
|
+
type: 'research',
|
|
1762
|
+
spec_id: res.specId,
|
|
1763
|
+
basename: res.basename ?? ctx.research.basename,
|
|
1764
|
+
rel_path: res.relPath,
|
|
1765
|
+
});
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
return cloudWriteFailureResult(err, {
|
|
1768
|
+
step: { kind: 'update', type: 'research', basename: ctx.research.basename },
|
|
1769
|
+
completed: cloudCompleted,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ── 6. Roadmap manifest append — EXPLICIT DEGRADE. `registerSpec` edits
|
|
1775
|
+
// `roadmap_manifest.milestones` inside a roadmap doc, and `roadmap`
|
|
1776
|
+
// is not one of the console's creatable/updatable spec types (its
|
|
1777
|
+
// TYPE_TO_FOLDER has no entry for it), so there is no route to make
|
|
1778
|
+
// this write through. Reported, never silently skipped.
|
|
1779
|
+
const roadmapRegistrationCloud = milestoneCreated
|
|
1780
|
+
? {
|
|
1781
|
+
ok: false,
|
|
1782
|
+
...cloudModeUnsupportedWrite(
|
|
1783
|
+
`${args.roadmap_id || 'HS-ROADMAP-PRODUCT'} (roadmap_manifest.milestones)`,
|
|
1784
|
+
'a roadmap doc is not one of the spec types the console can create or update (no TYPE_TO_FOLDER entry for "roadmap"), so there is no POST/PUT route for the manifest append',
|
|
1785
|
+
{ milestone_basename: milestoneBasename },
|
|
1786
|
+
),
|
|
1787
|
+
}
|
|
1788
|
+
: undefined;
|
|
1789
|
+
|
|
1790
|
+
return {
|
|
1791
|
+
phase: 'executed',
|
|
1792
|
+
write_mode: 'cloud',
|
|
1793
|
+
milestone_id: milestoneId,
|
|
1794
|
+
milestone_basename: milestoneBasename,
|
|
1795
|
+
milestone_path: milestoneRelPath,
|
|
1796
|
+
milestone_created: milestoneCreated,
|
|
1797
|
+
spawned_epics: spawnedEpicSpecs.map((s) => ({
|
|
1798
|
+
id: s.id,
|
|
1799
|
+
basename: s.basename,
|
|
1800
|
+
path: s.relPath,
|
|
1801
|
+
})),
|
|
1802
|
+
augmented_epics: augmentEpics.map((e) => e.basename),
|
|
1803
|
+
research_id: ctx.research.frontmatter?.id ?? args.research_id,
|
|
1804
|
+
research_path: ctx.research.relPath,
|
|
1805
|
+
research_status_after: ctx.researchStatus ?? 'open',
|
|
1806
|
+
writes: cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id })),
|
|
1807
|
+
human_review_required: false,
|
|
1808
|
+
...(depWarnings.length > 0 ? { depends_on_warnings: depWarnings } : {}),
|
|
1809
|
+
...(roadmapRegistrationCloud ? { roadmap_registration: roadmapRegistrationCloud } : {}),
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1228
1813
|
// 1. Milestone (create-if-new, else update later via line-surgical helper).
|
|
1229
1814
|
// Family inheritance (HS-TASK-QQHP defect #1) is resolved once, up top,
|
|
1230
1815
|
// where the era-3 basenames/ids are composed.
|
|
@@ -1401,10 +1986,20 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
|
|
|
1401
1986
|
const validationError = validateInputShape(args);
|
|
1402
1987
|
if (validationError) return validationError;
|
|
1403
1988
|
|
|
1989
|
+
// Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
|
|
1990
|
+
// `deps.tier` is resolved ONCE by the caller (`handlePromoteResearch` in
|
|
1991
|
+
// research-handlers.mjs, via `resolveReadTier()`) and threaded down —
|
|
1992
|
+
// never re-resolved here. Absent (every pre-existing caller/test) defaults
|
|
1993
|
+
// to `disk`, so disk-tier behavior is byte-identical to before this task.
|
|
1994
|
+
const tier = deps.tier ?? 'disk';
|
|
1995
|
+
const consoleUrl = deps.consoleUrl;
|
|
1996
|
+
const consoleFetchDep = deps.consoleFetch;
|
|
1997
|
+
const tierDeps = { tier, consoleUrl, consoleFetch: consoleFetchDep };
|
|
1998
|
+
|
|
1404
1999
|
// 1. Load research file. Resolution happens FIRST (exact basename → .md /
|
|
1405
2000
|
// wikilink-stripped → frontmatter id: → unambiguous prefix); the "is this
|
|
1406
2001
|
// really research?" check reads the resolved file's `type:`.
|
|
1407
|
-
const resolved =
|
|
2002
|
+
const resolved = await findResearchByIdViaTier(vaultRoot, args.research_id, tierDeps);
|
|
1408
2003
|
if (!resolved || resolved.error) {
|
|
1409
2004
|
return (
|
|
1410
2005
|
resolved ?? {
|
|
@@ -1451,7 +2046,7 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
|
|
|
1451
2046
|
}
|
|
1452
2047
|
|
|
1453
2048
|
// 2. Resolve target milestone.
|
|
1454
|
-
const targetResolution = resolveTargetMilestone(vaultRoot, args);
|
|
2049
|
+
const targetResolution = await resolveTargetMilestone(vaultRoot, args, tierDeps);
|
|
1455
2050
|
if (targetResolution.error) return targetResolution;
|
|
1456
2051
|
|
|
1457
2052
|
if (targetResolution.mode === 'existing') {
|
|
@@ -1476,12 +2071,17 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
|
|
|
1476
2071
|
);
|
|
1477
2072
|
if (attachPolicy.error) return attachPolicy;
|
|
1478
2073
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
2074
|
+
// [[HS-8XJA-KOGH-DIAG]]: an `enhances` set with no spawned_epics/enhances_epics
|
|
2075
|
+
// behind it was never actually promoted (see validateResearchState) — treat
|
|
2076
|
+
// it as unset rather than blocking on a signal the capture-promote step
|
|
2077
|
+
// forged one stage early. buildUpdatedResearchContent below overwrites
|
|
2078
|
+
// `enhances` unconditionally on a successful run, so this self-heals the
|
|
2079
|
+
// stray pointer instead of requiring a hand-edit to clear it.
|
|
2080
|
+
const conflict = researchState.enhancesBacked
|
|
2081
|
+
? validateBidirectionalEnhances(researchState.enhances, targetResolution.milestone.basename)
|
|
2082
|
+
: null;
|
|
1483
2083
|
if (conflict) return conflict;
|
|
1484
|
-
} else if (researchState.enhances) {
|
|
2084
|
+
} else if (researchState.enhances && researchState.enhancesBacked) {
|
|
1485
2085
|
// Already enhances a milestone but target is NEW — refuse, the human should
|
|
1486
2086
|
// either split the research or pick the existing milestone.
|
|
1487
2087
|
return {
|
|
@@ -1538,6 +2138,13 @@ export const __testables = {
|
|
|
1538
2138
|
validateInputShape,
|
|
1539
2139
|
normalizeResearchRef,
|
|
1540
2140
|
findResearchByIdLocal,
|
|
2141
|
+
findResearchByIdViaTier,
|
|
2142
|
+
findMilestoneByBasenameViaTier,
|
|
2143
|
+
findMilestoneByIdViaTier,
|
|
2144
|
+
findEpicByIdOrBasenameViaTier,
|
|
2145
|
+
resolvePillarViaTier,
|
|
2146
|
+
resolveOwnerIntentViaTier,
|
|
2147
|
+
resolveTargetMilestone,
|
|
1541
2148
|
validateExistingMilestoneAttachable,
|
|
1542
2149
|
MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES,
|
|
1543
2150
|
resolvePillar,
|