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
|
@@ -10,8 +10,26 @@
|
|
|
10
10
|
* 3. missing_manifest_refs — roadmap manifest entry pointing to a missing file
|
|
11
11
|
* (basename/unique-id match only — family idCore soft-match is intentionally
|
|
12
12
|
* NOT enough, so stale Naming V3 era-1/2 listings cannot greenlight).
|
|
13
|
+
* Every other ref resolves file-key-first (see buildSpecIndex /
|
|
14
|
+
* resolveSpecRef below, shared with register-spec.mjs): the family+TYPE
|
|
15
|
+
* core is a soft match for key-less legacy refs only, and only when unique.
|
|
13
16
|
* 4. parent_child_mismatches — one side of a parent/child link not pointing back.
|
|
14
17
|
*
|
|
18
|
+
* Plus one INFORMATIONAL (non-defect) bucket:
|
|
19
|
+
*
|
|
20
|
+
* 5. frozen_parent_child_mismatches — a parent/child mismatch where at least
|
|
21
|
+
* one side is `status: closed` / `status: superseded`. Per
|
|
22
|
+
* [[closed-trackable-specs-are-historical-record-not-living-docs]] and
|
|
23
|
+
* [[spec-hierarchy]] §"Closed specs are historical record — frozen +
|
|
24
|
+
* immutable", a closed/superseded spec's frontmatter is frozen: only the
|
|
25
|
+
* close-out change-log row, `status:`, `close_reason:` and `superseded_by:`
|
|
26
|
+
* may still be touched. A `children:` / `epics:` roster is NOT on that
|
|
27
|
+
* list, so roster bookkeeping on a frozen spec is a FORBIDDEN edit and the
|
|
28
|
+
* mismatch is unfixable by protocol — a validator false positive, not vault
|
|
29
|
+
* debt. These are reported separately so a reader can see they exist and
|
|
30
|
+
* why they are excluded, and they do NOT affect the top-level `valid`
|
|
31
|
+
* verdict.
|
|
32
|
+
*
|
|
15
33
|
* Reachability is expressed in the manifest-v3 hierarchy
|
|
16
34
|
* ([[HS-TASK-JN9H-manifest-schema-v3-and-horizon-retirement]]):
|
|
17
35
|
* roadmap → milestones (roadmap_manifest.milestones)
|
|
@@ -31,13 +49,33 @@
|
|
|
31
49
|
*/
|
|
32
50
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
33
51
|
import { join, relative } from 'node:path';
|
|
34
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
toCanonicalFamilyFirstId,
|
|
54
|
+
parseSpecId,
|
|
55
|
+
fileKeyFromBasename,
|
|
56
|
+
lookupFileKeyOf,
|
|
57
|
+
bareFileKeyOf,
|
|
58
|
+
} from './spec-id-lib.mjs';
|
|
35
59
|
|
|
36
60
|
// Entity kinds that must be reachable from the roadmap. Everything else
|
|
37
61
|
// (research, protocol, memory-*, capture, diagnose, agent, review, adr, …) is
|
|
38
62
|
// exempt — those are referenced by wikilink, not parented into the manifest.
|
|
39
63
|
const SUBJECT_TYPES = new Set(['milestone', 'epic', 'task']);
|
|
40
64
|
|
|
65
|
+
// Statuses that freeze a spec's frontmatter as historical record. Roster
|
|
66
|
+
// bookkeeping (`children:` / `epics:`) on one of these is a forbidden edit per
|
|
67
|
+
// [[closed-trackable-specs-are-historical-record-not-living-docs]] — the roster
|
|
68
|
+
// is accurate to what was true at close, and "fixing" it corrupts the record.
|
|
69
|
+
const FROZEN_STATUSES = new Set(['closed', 'superseded']);
|
|
70
|
+
|
|
71
|
+
// A spec is frozen when its `status:` is closed/superseded. Records built by an
|
|
72
|
+
// older caller (or a test fixture) may carry no `status` at all — absent status
|
|
73
|
+
// is never frozen, so the defect path stays the default.
|
|
74
|
+
function isFrozenSpec(spec) {
|
|
75
|
+
const status = spec && typeof spec.status === 'string' ? spec.status.trim().toLowerCase() : '';
|
|
76
|
+
return FROZEN_STATUSES.has(status);
|
|
77
|
+
}
|
|
78
|
+
|
|
41
79
|
// ---------------------------------------------------------------------------
|
|
42
80
|
// Frontmatter extraction (regex-over-raw — byte-tolerant, no YAML dependency)
|
|
43
81
|
// ---------------------------------------------------------------------------
|
|
@@ -73,6 +111,47 @@ function listItems(block, field) {
|
|
|
73
111
|
return out;
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
// `field:` may be written as an inline YAML flow list — `aliases: [foo, bar]`
|
|
115
|
+
// — rather than the block-list form `listItems()` handles. Splits on commas;
|
|
116
|
+
// each item is unquoted/trimmed. Returns null when the field is absent or not
|
|
117
|
+
// a flow list (caller falls back to listItems()).
|
|
118
|
+
function flowListItems(block, field) {
|
|
119
|
+
// Quote-aware: an item may contain `,` or `]` inside quotes (e.g.
|
|
120
|
+
// `"/report/[token]"`, `"[[basename]] links"`), so the closing `]` is the one
|
|
121
|
+
// that ends the line, not the first one seen.
|
|
122
|
+
const m = block.match(new RegExp(`^[ \\t]*${field}:[ \\t]*\\[(.*)\\][ \\t]*$`, 'm'));
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
const inner = m[1].trim();
|
|
125
|
+
if (!inner) return [];
|
|
126
|
+
const items = [];
|
|
127
|
+
let cur = '';
|
|
128
|
+
let quote = null;
|
|
129
|
+
for (const ch of inner) {
|
|
130
|
+
if (quote) {
|
|
131
|
+
if (ch === quote) quote = null;
|
|
132
|
+
else cur += ch;
|
|
133
|
+
} else if (ch === '"' || ch === "'") {
|
|
134
|
+
quote = ch;
|
|
135
|
+
} else if (ch === ',') {
|
|
136
|
+
items.push(cur);
|
|
137
|
+
cur = '';
|
|
138
|
+
} else {
|
|
139
|
+
cur += ch;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
items.push(cur);
|
|
143
|
+
return items.map((x) => x.trim()).filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `aliases:` — HS-WEGK-OQGQ: a wikilink target matching any of a spec's
|
|
147
|
+
// declared aliases must resolve, honoring both the inline flow-list form
|
|
148
|
+
// (`aliases: [foo, bar]`) and the block-list form.
|
|
149
|
+
function aliasesField(block) {
|
|
150
|
+
const flow = flowListItems(block, 'aliases');
|
|
151
|
+
if (flow !== null) return flow;
|
|
152
|
+
return listItems(block, 'aliases');
|
|
153
|
+
}
|
|
154
|
+
|
|
76
155
|
// `[[Foo|alias]]` / `[[Foo#anchor]]` / `Foo` → `Foo`. Returns null for empties.
|
|
77
156
|
function stripWikilink(value) {
|
|
78
157
|
if (typeof value !== 'string') return null;
|
|
@@ -97,6 +176,237 @@ function idCore(value) {
|
|
|
97
176
|
return m ? m[1].toUpperCase() : null;
|
|
98
177
|
}
|
|
99
178
|
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Naming V3 identity ([[HS-J6IE-K2EL-DIAG-heyspex-register-spec-resolves-a-task]])
|
|
181
|
+
//
|
|
182
|
+
// Under Naming V3 (`HS-<fileKey>-<familyKey>-<TYPE>-<slug>`) the UNIQUE key of a
|
|
183
|
+
// spec is its FILE key — the token right after `HS-`. The family key is a
|
|
184
|
+
// surname shared by a whole milestone's descendants, so `idCore` (family+TYPE)
|
|
185
|
+
// is NOT an identity: two tasks in one family (HS-OCL4-NYPR-TASK-… and
|
|
186
|
+
// HS-0HOF-NYPR-TASK-…) collapse to the same idCore. A first-wins idCore map
|
|
187
|
+
// therefore resolved one to the other and `heyspex_register_spec` re-parented
|
|
188
|
+
// an unrelated spec. The helpers below are the ONE resolution rule both this
|
|
189
|
+
// module and register-spec.mjs use:
|
|
190
|
+
//
|
|
191
|
+
// basename (exact) → id (exact) → file key (unique) → [aliases, opt-in]
|
|
192
|
+
// → idCore soft match ONLY for a ref that carries no file key, and ONLY when
|
|
193
|
+
// exactly one spec matches; two or more → AMBIGUOUS (candidates named).
|
|
194
|
+
// A bare family token (`HS-NYPR`) that matches no file key but names a
|
|
195
|
+
// family is AMBIGUOUS too, never a silent pick.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
// File key carried by a ref in any accepted form: bare `HS-OCL4`, display
|
|
199
|
+
// `HS-OCL4-NYPR`, full era-3 id/basename, a `[[wikilink]]` already stripped by
|
|
200
|
+
// the caller, or an era-1/2 basename whose slug leads with the key.
|
|
201
|
+
function refFileKey(ref) {
|
|
202
|
+
if (typeof ref !== 'string' || !ref.trim()) return null;
|
|
203
|
+
// Pasted/typed refs may arrive as `hs-ocl4`; the stored shape is `HS-`.
|
|
204
|
+
const t = ref.trim().replace(/^hs-/i, 'HS-');
|
|
205
|
+
return lookupFileKeyOf(t) ?? fileKeyFromBasename(t) ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The file key a spec record is STORED under: basename first (the era-3
|
|
209
|
+
// filename always leads with it), then the stored `id:` — but only in its
|
|
210
|
+
// stored shapes (bare `HS-OCL4` or a full era-3 id). The two-token DISPLAY form
|
|
211
|
+
// lookupFileKeyOf accepts for pasted refs is never a stored id, and reading it
|
|
212
|
+
// here would misfile e.g. `HS-RECIPE-feature` under file key "RECIPE".
|
|
213
|
+
function storedFileKey(basename, id) {
|
|
214
|
+
const fromBasename = refFileKey(basename);
|
|
215
|
+
if (fromBasename) return fromBasename;
|
|
216
|
+
if (typeof id !== 'string' || !id.trim()) return null;
|
|
217
|
+
const bare = bareFileKeyOf(id);
|
|
218
|
+
if (bare) return bare;
|
|
219
|
+
const p = parseSpecId(id);
|
|
220
|
+
if (p?.era === 3 && p.fileKey) return p.fileKey;
|
|
221
|
+
return fileKeyFromBasename(id) ?? null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function specFileKey(spec) {
|
|
225
|
+
if (!spec) return null;
|
|
226
|
+
if (spec.fileKey !== undefined) return spec.fileKey;
|
|
227
|
+
return storedFileKey(spec.basename, spec.id);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Family key of a spec record: explicit frontmatter `family:` when the walker
|
|
231
|
+
// captured it, else the positional family parsed from basename/id.
|
|
232
|
+
function specFamilyKey(spec) {
|
|
233
|
+
if (!spec) return null;
|
|
234
|
+
if (typeof spec.family === 'string' && spec.family.trim()) return spec.family.trim().toUpperCase();
|
|
235
|
+
const p = parseSpecId(spec.basename) ?? parseSpecId(spec.id);
|
|
236
|
+
return p?.familyKey ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pushMulti = (map, key, value) => {
|
|
240
|
+
if (!key) return;
|
|
241
|
+
const arr = map.get(key);
|
|
242
|
+
if (arr) arr.push(value);
|
|
243
|
+
else map.set(key, [value]);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build the lookup index every resolver below reads. Collisions are KEPT (each
|
|
248
|
+
* multimap holds every spec for a key) so a lookup can refuse ambiguity instead
|
|
249
|
+
* of taking the first file the directory walk happened to yield.
|
|
250
|
+
*/
|
|
251
|
+
export function buildSpecIndex(specs) {
|
|
252
|
+
const byBasename = new Map();
|
|
253
|
+
const byId = new Map(); // id → spec[]
|
|
254
|
+
const byFileKey = new Map(); // fileKey → spec[]
|
|
255
|
+
const byIdCore = new Map(); // family+TYPE core → spec[]
|
|
256
|
+
const byFamily = new Map(); // familyKey → spec[]
|
|
257
|
+
const byAlias = new Map();
|
|
258
|
+
for (const s of specs) {
|
|
259
|
+
byBasename.set(s.basename, s);
|
|
260
|
+
if (s.id) pushMulti(byId, s.id, s);
|
|
261
|
+
pushMulti(byFileKey, specFileKey(s), s);
|
|
262
|
+
const core = s.idCore ?? idCore(s.id) ?? idCore(s.basename);
|
|
263
|
+
pushMulti(byIdCore, core, s);
|
|
264
|
+
pushMulti(byFamily, specFamilyKey(s), s);
|
|
265
|
+
for (const a of s.aliases || []) {
|
|
266
|
+
if (a && !byAlias.has(a)) byAlias.set(a, s);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A review companion (`type: review` — `<subject>.review.md` beside an epic,
|
|
270
|
+
// or `HS-<key>-<family>-REV-…` under specs/reviews) carries its SUBJECT's
|
|
271
|
+
// file key by convention. That is a pairing, not an identity collision, so
|
|
272
|
+
// the subject keeps sole claim to the key; two non-review files on one key
|
|
273
|
+
// stay a real (refused) collision.
|
|
274
|
+
const preferSubjects = (map) => {
|
|
275
|
+
for (const [key, arr] of map) {
|
|
276
|
+
if (arr.length < 2) continue;
|
|
277
|
+
const subjects = arr.filter((s) => s.type !== 'review');
|
|
278
|
+
if (subjects.length >= 1 && subjects.length < arr.length) map.set(key, subjects);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
preferSubjects(byFileKey);
|
|
282
|
+
preferSubjects(byIdCore);
|
|
283
|
+
preferSubjects(byFamily);
|
|
284
|
+
return { byBasename, byId, byFileKey, byIdCore, byFamily, byAlias };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Title slug of an id/basename in any era, or '' (used only to break an
|
|
288
|
+
// idCore tie between a slugged legacy ref and the one sibling that kept its slug).
|
|
289
|
+
function titleSlugOf(value) {
|
|
290
|
+
const p = parseSpecId(value);
|
|
291
|
+
return p?.titleSlug ? p.titleSlug.toLowerCase() : '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const candidateList = (specs) =>
|
|
295
|
+
specs.map((s) => ({ id: s.id, basename: s.basename, file: s.file ?? null }));
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Resolve one reference against the index.
|
|
299
|
+
*
|
|
300
|
+
* @returns {{ spec: object|null, ambiguous: boolean, candidates: object[], matched_by: string|null }}
|
|
301
|
+
* `spec` is set on a unique hit; `ambiguous: true` + `candidates` when the
|
|
302
|
+
* ref names more than one spec; both empty when nothing matched.
|
|
303
|
+
*/
|
|
304
|
+
export function resolveSpecRef(index, ref, { aliases = false, pathFallback = false } = {}) {
|
|
305
|
+
const none = { spec: null, ambiguous: false, candidates: [], matched_by: null };
|
|
306
|
+
if (!ref) return none;
|
|
307
|
+
const raw = stripWikilink(ref) ?? String(ref);
|
|
308
|
+
const t = raw.replace(/\.md$/i, '').trim();
|
|
309
|
+
if (!t) return none;
|
|
310
|
+
|
|
311
|
+
const unique = (arr, how) =>
|
|
312
|
+
arr && arr.length === 1
|
|
313
|
+
? { spec: arr[0], ambiguous: false, candidates: [], matched_by: how }
|
|
314
|
+
: arr && arr.length > 1
|
|
315
|
+
? { spec: null, ambiguous: true, candidates: candidateList(arr), matched_by: how }
|
|
316
|
+
: null;
|
|
317
|
+
|
|
318
|
+
const exact = index.byBasename.get(t);
|
|
319
|
+
if (exact) return { spec: exact, ambiguous: false, candidates: [], matched_by: 'basename' };
|
|
320
|
+
|
|
321
|
+
const byId = unique(index.byId.get(t), 'id');
|
|
322
|
+
if (byId) return byId;
|
|
323
|
+
|
|
324
|
+
const fileKey = refFileKey(t);
|
|
325
|
+
if (fileKey) {
|
|
326
|
+
const hit = unique(index.byFileKey.get(fileKey), 'file_key');
|
|
327
|
+
if (hit) return hit;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (aliases) {
|
|
331
|
+
const alias = index.byAlias.get(t);
|
|
332
|
+
if (alias) return { spec: alias, ambiguous: false, candidates: [], matched_by: 'alias' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A ref that CARRIES a file key named exactly one spec or nothing — never a
|
|
336
|
+
// sibling. Only a key-less (era-1/2) ref may fall through to the soft match.
|
|
337
|
+
if (!fileKey) {
|
|
338
|
+
const coreHits = index.byIdCore.get(idCore(t));
|
|
339
|
+
if (coreHits && coreHits.length > 1) {
|
|
340
|
+
// Pre-rename slugged link (`HS-Q1HZ-TASK-hs-vault-cli-…`) vs the era-3
|
|
341
|
+
// sibling that kept that exact slug (`HS-GLPP-Q1HZ-TASK-hs-vault-cli-…`):
|
|
342
|
+
// family + TYPE + identical slug is one file, not a family. Slug-less or
|
|
343
|
+
// differently-slugged refs stay ambiguous.
|
|
344
|
+
const slug = titleSlugOf(t);
|
|
345
|
+
const sameSlug = slug ? coreHits.filter((s) => titleSlugOf(s.basename) === slug) : [];
|
|
346
|
+
if (sameSlug.length === 1) {
|
|
347
|
+
return { spec: sameSlug[0], ambiguous: false, candidates: [], matched_by: 'id_core_slug' };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const hit = unique(coreHits, 'id_core');
|
|
351
|
+
if (hit) return hit;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Bare token (`HS-XXXX`): bareFileKeyOf is shape-only, so a FAMILY key reads
|
|
355
|
+
// as a file key too. No file-key hit above → if it names a family, refuse.
|
|
356
|
+
const bare = bareFileKeyOf(t);
|
|
357
|
+
if (bare) {
|
|
358
|
+
const fam = index.byFamily.get(bare);
|
|
359
|
+
if (fam && fam.length) {
|
|
360
|
+
return { spec: null, ambiguous: true, candidates: candidateList(fam), matched_by: 'family' };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (pathFallback && t.includes('/')) {
|
|
365
|
+
const base = t.split('/').pop();
|
|
366
|
+
const b = index.byBasename.get(base);
|
|
367
|
+
if (b) return { spec: b, ambiguous: false, candidates: [], matched_by: 'basename' };
|
|
368
|
+
const i = unique(index.byId.get(base), 'id');
|
|
369
|
+
if (i) return i;
|
|
370
|
+
if (aliases) {
|
|
371
|
+
const a = index.byAlias.get(base);
|
|
372
|
+
if (a) return { spec: a, ambiguous: false, candidates: [], matched_by: 'alias' };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Does `ref` (a list entry / pointer field) name exactly `spec`? Exact
|
|
380
|
+
* basename or id, or the same file key. The family+TYPE idCore is accepted
|
|
381
|
+
* ONLY when neither side carries a file key (two legacy era-1/2 strings) —
|
|
382
|
+
* once a file key exists on either side it is the identity, and a stale
|
|
383
|
+
* family-core string must not be mistaken for a reference to this spec.
|
|
384
|
+
*/
|
|
385
|
+
export function refNamesSpec(ref, spec) {
|
|
386
|
+
if (!ref || !spec) return false;
|
|
387
|
+
const t = (stripWikilink(ref) ?? String(ref)).replace(/\.md$/i, '').trim();
|
|
388
|
+
if (!t) return false;
|
|
389
|
+
if (t === spec.basename || t === spec.id) return true;
|
|
390
|
+
const refKey = refFileKey(t);
|
|
391
|
+
const specKey = specFileKey(spec);
|
|
392
|
+
if (refKey || specKey) return !!refKey && refKey === specKey;
|
|
393
|
+
const core = spec.idCore ?? idCore(spec.id) ?? idCore(spec.basename);
|
|
394
|
+
return !!core && idCore(t) === core;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Text-level twin of {@link refNamesSpec} for two bare ref strings. */
|
|
398
|
+
export function refsNameSameSpec(a, b) {
|
|
399
|
+
const ta = (stripWikilink(a) ?? String(a ?? '')).replace(/\.md$/i, '').trim();
|
|
400
|
+
const tb = (stripWikilink(b) ?? String(b ?? '')).replace(/\.md$/i, '').trim();
|
|
401
|
+
if (!ta || !tb) return false;
|
|
402
|
+
if (ta === tb) return true;
|
|
403
|
+
const ka = refFileKey(ta);
|
|
404
|
+
const kb = refFileKey(tb);
|
|
405
|
+
if (ka || kb) return !!ka && ka === kb;
|
|
406
|
+
const ca = idCore(ta);
|
|
407
|
+
return !!ca && ca === idCore(tb);
|
|
408
|
+
}
|
|
409
|
+
|
|
100
410
|
// Every "real" `[[wikilink]]` in the file, with 1-based line numbers for
|
|
101
411
|
// actionable reporting. Links inside fenced code blocks (``` / ~~~) and inline
|
|
102
412
|
// code spans (`...`) are skipped — specs routinely show `[[example]]` syntax as
|
|
@@ -182,6 +492,33 @@ function extractLineageTableLinks(raw) {
|
|
|
182
492
|
* Read every `.md` under `<vaultRoot>/specs/` into a normalized record the
|
|
183
493
|
* analyzer consumes. Exported so the analyzer can be unit-tested with synthetic
|
|
184
494
|
* records (no disk) while the real path walks the vault.
|
|
495
|
+
*
|
|
496
|
+
* ## Why this one stays a whole-vault walk
|
|
497
|
+
*
|
|
498
|
+
* Judged by [[HS-FDVS-8KK5-TASK-route-sweep-every-api-route-reads]] under
|
|
499
|
+
* [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]], and kept.
|
|
500
|
+
* Every answer this module produces is a property of the ENTIRE graph, not of
|
|
501
|
+
* any page of it:
|
|
502
|
+
*
|
|
503
|
+
* - `orphan_specs` — "not reachable from the roadmap" is only knowable once
|
|
504
|
+
* every edge has been seen. A page cannot tell an orphan from a spec whose
|
|
505
|
+
* parent lives on the next page.
|
|
506
|
+
* - `broken_wikilinks` — a link resolves against the set of all filenames.
|
|
507
|
+
* - `parent_child_mismatches` — needs both ends of every link at once.
|
|
508
|
+
*
|
|
509
|
+
* Paging the input would not make the analysis cheaper, only wrong. Note also
|
|
510
|
+
* what this is NOT: it is not a console request path. It runs from the
|
|
511
|
+
* `heyspex_validate_linkage` MCP tool and the pre-commit `validate` CLI verb —
|
|
512
|
+
* both explicitly whole-vault operations a human or agent asked for, and both
|
|
513
|
+
* outside the "no request path does work proportional to vault size" rule the
|
|
514
|
+
* epic enforces. Its RESPONSE size is bounded separately by
|
|
515
|
+
* [[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]].
|
|
516
|
+
*
|
|
517
|
+
* The walk itself retires when linkage validity is maintained incrementally
|
|
518
|
+
* from the outbox instead of recomputed by re-reading the vault
|
|
519
|
+
* ([[HS-D26S-8KK5-EPIC-incremental-indexing-from-the-outbox]]); no task under
|
|
520
|
+
* it covers this module yet, so that is the follow-up to file, not a fix to
|
|
521
|
+
* make here.
|
|
185
522
|
*/
|
|
186
523
|
export function collectVaultSpecs(vaultRoot) {
|
|
187
524
|
const specsDir = join(vaultRoot, 'specs');
|
|
@@ -202,11 +539,30 @@ export function collectVaultSpecs(vaultRoot) {
|
|
|
202
539
|
specs.push({
|
|
203
540
|
id,
|
|
204
541
|
type,
|
|
542
|
+
// `status:` drives the frozen-spec carve-out for parent/child mismatches
|
|
543
|
+
// (see FROZEN_STATUSES). Read as a plain scalar — absent means not frozen.
|
|
544
|
+
status: scalarField(block, 'status'),
|
|
205
545
|
basename,
|
|
206
546
|
idCore: idCore(id) || idCore(basename),
|
|
547
|
+
// Naming V3 identity: the file key is the UNIQUE key (see specFileKey);
|
|
548
|
+
// `family:` is the shared surname, kept only so a bare family token can
|
|
549
|
+
// be refused as ambiguous rather than silently matched.
|
|
550
|
+
fileKey: storedFileKey(basename, id),
|
|
551
|
+
family: scalarField(block, 'family'),
|
|
207
552
|
file: relative(vaultRoot, abs).split('\\').join('/'),
|
|
208
|
-
|
|
209
|
-
|
|
553
|
+
aliases: aliasesField(block),
|
|
554
|
+
parent: stripWikilink(scalarField(block, 'parent')) || (() => {
|
|
555
|
+
// HS-OZFL-OPVL: `parent:` written as a one-item YAML block list rather
|
|
556
|
+
// than the canonical scalar form must still be read, or the spec is
|
|
557
|
+
// falsely flagged as an orphan.
|
|
558
|
+
const items = listItems(block, 'parent').map(stripWikilink).filter(Boolean);
|
|
559
|
+
return items[0] ?? null;
|
|
560
|
+
})(),
|
|
561
|
+
milestone: stripWikilink(scalarField(block, 'milestone')) || (() => {
|
|
562
|
+
// HS-OZFL-OPVL: same list-form fallback as `parent:` above, for epics.
|
|
563
|
+
const items = listItems(block, 'milestone').map(stripWikilink).filter(Boolean);
|
|
564
|
+
return items[0] ?? null;
|
|
565
|
+
})(),
|
|
210
566
|
children: listItems(block, 'children').map(stripWikilink).filter(Boolean),
|
|
211
567
|
epics: listItems(block, 'epics').map(stripWikilink).filter(Boolean),
|
|
212
568
|
related: listItems(block, 'related').map(stripWikilink).filter(Boolean),
|
|
@@ -243,20 +599,19 @@ export function collectVaultSpecs(vaultRoot) {
|
|
|
243
599
|
* `--staged-only`). null = whole vault.
|
|
244
600
|
*/
|
|
245
601
|
export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null } = {}) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
};
|
|
602
|
+
// One shared index + resolver with register-spec.mjs (HS-J6IE): basename →
|
|
603
|
+
// id → unique file key → alias → idCore soft match (key-less refs only, and
|
|
604
|
+
// only when unique). An AMBIGUOUS ref (names two specs in one family) is
|
|
605
|
+
// treated as NOT resolving here — it cannot be a valid edge to one spec, and
|
|
606
|
+
// it must never be satisfied by whichever sibling the walk met first.
|
|
607
|
+
//
|
|
608
|
+
// HS-WEGK-OQGQ: `.md` is stripped and a folder-prefixed link
|
|
609
|
+
// (`[[protocols/testing-strategy]]`) falls back to its last path segment so
|
|
610
|
+
// it resolves folder-agnostically (spex-console/src/lib/vault-shared.ts).
|
|
611
|
+
const index = buildSpecIndex(specs);
|
|
612
|
+
const byBasename = index.byBasename;
|
|
613
|
+
const byId = index.byId;
|
|
614
|
+
const resolveRef = (ref) => resolveSpecRef(index, ref, { aliases: true, pathFallback: true }).spec;
|
|
260
615
|
// Manifest entries must name an on-disk basename (or unique bare id). Family
|
|
261
616
|
// idCore alone is NOT enough — after Naming V3, an era-1/2 `HS-<family>-MIL-…`
|
|
262
617
|
// string shares idCore with the era-3 file but does not match the console's
|
|
@@ -264,13 +619,11 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
264
619
|
const resolveManifestRef = (ref) => {
|
|
265
620
|
if (!ref) return null;
|
|
266
621
|
const t = stripWikilink(ref) ?? ref;
|
|
267
|
-
|
|
622
|
+
const ids = byId.get(t);
|
|
623
|
+
return byBasename.get(t) || (ids && ids.length === 1 ? ids[0] : null) || null;
|
|
268
624
|
};
|
|
269
625
|
const refResolves = (ref) => resolveRef(ref) != null;
|
|
270
|
-
const refMatchesSpec = (ref, spec) =>
|
|
271
|
-
const t = stripWikilink(ref) ?? ref;
|
|
272
|
-
return t === spec.basename || t === spec.id || (!!spec.idCore && idCore(t) === spec.idCore);
|
|
273
|
-
};
|
|
626
|
+
const refMatchesSpec = (ref, spec) => refNamesSpec(ref, spec);
|
|
274
627
|
const listContainsSpec = (list, spec) => list.some((ref) => refMatchesSpec(ref, spec));
|
|
275
628
|
|
|
276
629
|
// Manifest reachability index — union of every roadmap's milestone entries.
|
|
@@ -285,11 +638,13 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
285
638
|
}
|
|
286
639
|
const milestoneInManifest = (spec) =>
|
|
287
640
|
manifestMilestoneRefs.has(spec.basename) || manifestMilestoneRefs.has(spec.id);
|
|
288
|
-
const inScope = (spec) =>
|
|
289
|
-
!scopeIds
|
|
290
|
-
scopeIds.has(spec.id) ||
|
|
291
|
-
|
|
292
|
-
(
|
|
641
|
+
const inScope = (spec) => {
|
|
642
|
+
if (!scopeIds) return true;
|
|
643
|
+
if (scopeIds.has(spec.id) || scopeIds.has(spec.basename)) return true;
|
|
644
|
+
const key = specFileKey(spec);
|
|
645
|
+
if (key && (scopeIds.has(`HS-${key}`) || scopeIds.has(key))) return true;
|
|
646
|
+
return !!spec.idCore && scopeIds.has(spec.idCore);
|
|
647
|
+
};
|
|
293
648
|
|
|
294
649
|
// Amends reverse map ([[AMENDS-CONTRACT]] / HS-6DMR-EPIC) — the partial,
|
|
295
650
|
// non-terminal supersession tier. `amends:` lives only on the amending (newer)
|
|
@@ -343,14 +698,40 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
343
698
|
const broken_wikilinks = [];
|
|
344
699
|
const missing_manifest_refs = [];
|
|
345
700
|
const parent_child_mismatches = [];
|
|
701
|
+
// Informational, NOT a defect — see FROZEN_STATUSES and the module header.
|
|
702
|
+
const frozen_parent_child_mismatches = [];
|
|
346
703
|
const warnings = [];
|
|
347
704
|
const seenMismatch = new Set();
|
|
348
705
|
|
|
706
|
+
const FROZEN_NOTE =
|
|
707
|
+
'Frozen by protocol — at least one side is closed/superseded, whose roster (`children:`/`epics:`) ' +
|
|
708
|
+
'is historical record accurate to what was true at close. Editing it is a forbidden edit ' +
|
|
709
|
+
'([[closed-trackable-specs-are-historical-record-not-living-docs]]), so this mismatch is ' +
|
|
710
|
+
'intentionally unfixable and is excluded from the `valid` verdict. Take no action.';
|
|
711
|
+
|
|
349
712
|
const pushMismatch = (parent, child, issue, parentSpec, childSpec) => {
|
|
350
713
|
const key = `${parent}|${child}|${issue}`;
|
|
351
714
|
if (seenMismatch.has(key)) return;
|
|
352
715
|
seenMismatch.add(key);
|
|
353
716
|
if (!inScope(parentSpec) && !inScope(childSpec)) return;
|
|
717
|
+
// Either side frozen → informational bucket, never a defect. Note the
|
|
718
|
+
// unresolvable-ref call sites pass the parent spec for BOTH arguments; a
|
|
719
|
+
// frozen parent there is still correct (repairing it would mean editing the
|
|
720
|
+
// frozen roster), so the same either-side test covers that case.
|
|
721
|
+
const parentFrozen = isFrozenSpec(parentSpec);
|
|
722
|
+
const childFrozen = isFrozenSpec(childSpec);
|
|
723
|
+
if (parentFrozen || childFrozen) {
|
|
724
|
+
frozen_parent_child_mismatches.push({
|
|
725
|
+
parent,
|
|
726
|
+
child,
|
|
727
|
+
issue,
|
|
728
|
+
frozen_side: parentFrozen && childFrozen ? 'both' : parentFrozen ? 'parent' : 'child',
|
|
729
|
+
parent_status: parentSpec?.status ?? null,
|
|
730
|
+
child_status: childSpec?.status ?? null,
|
|
731
|
+
note: FROZEN_NOTE,
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
354
735
|
parent_child_mismatches.push({ parent, child, issue });
|
|
355
736
|
};
|
|
356
737
|
|
|
@@ -525,6 +906,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
525
906
|
}
|
|
526
907
|
}
|
|
527
908
|
|
|
909
|
+
// NOTE: frozen_parent_child_mismatches is intentionally absent here — a
|
|
910
|
+
// frozen-by-protocol mismatch is not vault debt and must never fail the gate.
|
|
528
911
|
const valid =
|
|
529
912
|
orphan_specs.length === 0 &&
|
|
530
913
|
broken_wikilinks.length === 0 &&
|
|
@@ -539,6 +922,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
539
922
|
broken_wikilinks,
|
|
540
923
|
missing_manifest_refs,
|
|
541
924
|
parent_child_mismatches,
|
|
925
|
+
// Informational only — deliberately excluded from `valid` above.
|
|
926
|
+
frozen_parent_child_mismatches,
|
|
542
927
|
broken_amends,
|
|
543
928
|
amended_by,
|
|
544
929
|
broken_iterates_on,
|
|
@@ -550,6 +935,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
|
|
|
550
935
|
broken_links: broken_wikilinks.length,
|
|
551
936
|
missing_refs: missing_manifest_refs.length,
|
|
552
937
|
mismatches: parent_child_mismatches.length,
|
|
938
|
+
// Informational count — frozen-by-protocol mismatches excluded from `mismatches`.
|
|
939
|
+
frozen_mismatches: frozen_parent_child_mismatches.length,
|
|
553
940
|
broken_amends: broken_amends.length,
|
|
554
941
|
broken_iterates_on: broken_iterates_on.length,
|
|
555
942
|
},
|