heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// spexharness/src/profile/learn.mjs
|
|
2
|
+
//
|
|
3
|
+
// P1 (epic [[HS-HEBV-8CMW-EPIC-spexcode-profile-v0-learn-the-developer]], task
|
|
4
|
+
// [[HS-3N98-8CMW-TASK-p1-spexcode-profile-learner-core]]) + P2 (task
|
|
5
|
+
// [[HS-6CE7-8CMW-TASK-p2-profile-learner-incremental-learned]]) — the SpexCode
|
|
6
|
+
// Profile learner core. Distils this machine's already-extracted Doctrine
|
|
7
|
+
// observation stream into a human-readable, cited profile file
|
|
8
|
+
// (`.heyspex/spexcode-profile.md`, machine-local, NEVER written to the vault
|
|
9
|
+
// under `specs/`).
|
|
10
|
+
//
|
|
11
|
+
// v0 is heuristic only — no model call (epic's own scope boundary; the trained
|
|
12
|
+
// classifier lives in [[HS-PYW7-PSHL-MIL-per-vault-ml-training-infrastructure]]
|
|
13
|
+
// and is explicitly out of scope here).
|
|
14
|
+
//
|
|
15
|
+
// Reuse, never duplicate:
|
|
16
|
+
// - spexharness/src/doctrine/corpus.mjs's `readObservations` is the ONLY way
|
|
17
|
+
// this module reads the observation stream — it never re-parses a raw
|
|
18
|
+
// session file, never re-implements JSONL streaming, and never re-derives
|
|
19
|
+
// a citation. DR1 already redacted every `text`/`context` before it ever
|
|
20
|
+
// reached `.heyspex/doctrine/observations.jsonl`.
|
|
21
|
+
// - spexharness/src/context-budget.mjs's `recordContextInjection` is the
|
|
22
|
+
// one context-injection measurement path in the harness; this module only
|
|
23
|
+
// WIRES the profile block's size through it (see
|
|
24
|
+
// `registerProfileContextUsage`) — the actual injection into a seat's
|
|
25
|
+
// context is a later task (epic scope item 4).
|
|
26
|
+
//
|
|
27
|
+
// Kinds this module classifies into profile sections: `instruction`,
|
|
28
|
+
// `correction`, `ruling`, `approval`, `refusal`, `taste` — the six kinds DR1's
|
|
29
|
+
// corpus.mjs produces. `behaviour` (DR-behaviour) and `vault-rule` (DR5,
|
|
30
|
+
// concurrent with this task) are deliberately SKIPPED: those are
|
|
31
|
+
// session-shape/vault-sourced signals, not the per-developer habit
|
|
32
|
+
// observations this v0 profile distils. A vault decision/lesson's automatic
|
|
33
|
+
// inclusion in the profile is a separate task (epic scope item 8, P7) — this
|
|
34
|
+
// module does not read the vault.
|
|
35
|
+
//
|
|
36
|
+
// Incremental (P2): `.heyspex/profile-state.json` (`loadProfileState` /
|
|
37
|
+
// `saveProfileState`) carries a cursor (`lastObservationSeq` — a count of
|
|
38
|
+
// observations already folded into the profile, not a per-session `seq`
|
|
39
|
+
// field; the observation log is append-only so a simple position cursor is a
|
|
40
|
+
// correct, and much simpler, "or equivalent" to a true monotonic sequence)
|
|
41
|
+
// plus the already-classified section entries themselves, so a re-run only
|
|
42
|
+
// classifies NEW observations and the previously-written section entries are
|
|
43
|
+
// carried through unchanged — the rendered `.md` file's pre-existing
|
|
44
|
+
// observation lines come out byte-identical, because they are re-rendered
|
|
45
|
+
// from the exact same stored entries in the exact same order, never
|
|
46
|
+
// re-derived from a fresh markdown parse.
|
|
47
|
+
//
|
|
48
|
+
// Cold-run cap (P2): `maxObservations` bounds how many NEW observations a
|
|
49
|
+
// single `buildProfile` call folds in; anything beyond the cap is left for
|
|
50
|
+
// the next call (the cursor only advances past what was actually processed),
|
|
51
|
+
// and the return value reports how many were deferred.
|
|
52
|
+
|
|
53
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
54
|
+
import { dirname, join } from 'node:path';
|
|
55
|
+
|
|
56
|
+
import { readObservations } from '../doctrine/corpus.mjs';
|
|
57
|
+
import { recordContextInjection } from '../context-budget.mjs';
|
|
58
|
+
|
|
59
|
+
/** Observation kinds this v0 developer-profile deliberately does not learn from. */
|
|
60
|
+
const SKIPPED_KINDS = new Set(['behaviour', 'vault-rule']);
|
|
61
|
+
|
|
62
|
+
/** Cold-run cap: default max NEW observations a single `buildProfile` call folds in. */
|
|
63
|
+
export const DEFAULT_MAX_OBSERVATIONS = 500;
|
|
64
|
+
|
|
65
|
+
// ── size caps (HS — profile context blowout) ────────────────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// Three caps, because the profile blew the context window three different ways at once and
|
|
68
|
+
// each one is independently sufficient to do it again:
|
|
69
|
+
//
|
|
70
|
+
// 1. MAX_ENTRY_CHARS — ONE observation's text. DR1's corpus happily extracts a whole
|
|
71
|
+
// pasted document as a single `instruction`/`taste` observation; the two largest
|
|
72
|
+
// entries on this machine were 244KB and 242KB EACH. An entry is a one-line habit
|
|
73
|
+
// citation, never a document, so it is clamped (and whitespace-collapsed — see
|
|
74
|
+
// `clampEntryText`, embedded newlines also broke the rendered markdown structure).
|
|
75
|
+
// 2. MAX_ENTRIES_PER_SECTION — total retained entries. `maxObservations` only bounds how
|
|
76
|
+
// many NEW observations ONE run folds in; sections themselves were append-forever, so
|
|
77
|
+
// every run permanently added up to 500 more lines. Most recent entries win.
|
|
78
|
+
// 3. PROFILE_MAX_TOKENS — the injection budget, enforced at render (session-bootstrap.mjs's
|
|
79
|
+
// `renderProfileBlock`), mirroring DOCTRINE_MAX_TOKENS. This is the load-bearing one:
|
|
80
|
+
// it bounds what reaches a seat's context REGARDLESS of what is on disk, so a profile
|
|
81
|
+
// file that grew huge under an older build can never blow a context window again.
|
|
82
|
+
|
|
83
|
+
/** Max characters ONE observation's text keeps as a profile entry. */
|
|
84
|
+
export const MAX_ENTRY_CHARS = 400;
|
|
85
|
+
|
|
86
|
+
/** Max entries retained per section; the most recent are kept. */
|
|
87
|
+
export const MAX_ENTRIES_PER_SECTION = 200;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Injection budget for the rendered profile block, in tokens — the profile's counterpart to
|
|
91
|
+
* ../doctrine/inject.mjs's `DOCTRINE_MAX_TOKENS` (800). Enforced by session-bootstrap.mjs's
|
|
92
|
+
* `renderProfileBlock`, which imports it from here the same way that module imports
|
|
93
|
+
* `DOCTRINE_MAX_TOKENS` from doctrine/inject.mjs.
|
|
94
|
+
*/
|
|
95
|
+
export const PROFILE_MAX_TOKENS = 1200;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clamp ONE observation's text to a single, bounded profile line.
|
|
99
|
+
*
|
|
100
|
+
* Collapses whitespace FIRST: `citationLine` renders `- <text> _(citation)_` as one markdown
|
|
101
|
+
* list item, so a text carrying its own newlines (a pasted document, a stack trace) did not
|
|
102
|
+
* merely take up room — its embedded `## ...`/``` lines were re-parsed as real markdown
|
|
103
|
+
* structure, which is why the rendered profile grew headings like "Error Message" and
|
|
104
|
+
* "Code Frame" that no section in SECTION_ORDER ever defines.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} text
|
|
107
|
+
* @param {{ maxChars?: number }} [opts]
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function clampEntryText(text, { maxChars = MAX_ENTRY_CHARS } = {}) {
|
|
111
|
+
const collapsed = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
112
|
+
if (collapsed.length <= maxChars) return collapsed;
|
|
113
|
+
return `${collapsed.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Keep a section within {@link MAX_ENTRIES_PER_SECTION}, preferring the most recent entries. */
|
|
117
|
+
function trimSection(entries) {
|
|
118
|
+
return entries.length > MAX_ENTRIES_PER_SECTION ? entries.slice(-MAX_ENTRIES_PER_SECTION) : entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fixed section order — every rendered profile uses this order; empty sections are omitted
|
|
123
|
+
* (both `renderProfileMarkdown` and `renderProfileDigest` skip a section with no entries, so
|
|
124
|
+
* `unclassified` is invisible on a profile that has none).
|
|
125
|
+
*
|
|
126
|
+
* `unclassified` exists because "Preferred languages and idioms" used to be the FALLBACK for
|
|
127
|
+
* everything that was not an approval/refusal/correction and carried no test/commit keyword.
|
|
128
|
+
* A plain bug report was therefore served to a seat under the heading "Preferred languages and
|
|
129
|
+
* idioms", i.e. as a language preference the owner never expressed. Nothing is dropped — an
|
|
130
|
+
* observation that fits no section is filed here, honestly labelled, instead of being
|
|
131
|
+
* mislabelled.
|
|
132
|
+
*/
|
|
133
|
+
export const SECTION_ORDER = Object.freeze([
|
|
134
|
+
{ key: 'languages', heading: 'Preferred languages and idioms' },
|
|
135
|
+
{ key: 'tests', heading: 'Test style' },
|
|
136
|
+
{ key: 'commits', heading: 'Commit and message conventions' },
|
|
137
|
+
{ key: 'tools', heading: 'Tools approved or refused' },
|
|
138
|
+
{ key: 'corrections', heading: 'Recurring corrections' },
|
|
139
|
+
{ key: 'unclassified', heading: 'Unclassified observations' },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const SECTION_KEYS = SECTION_ORDER.map((s) => s.key);
|
|
143
|
+
|
|
144
|
+
// ── keyword matching ──────────────────────────────────────────────────────────────
|
|
145
|
+
//
|
|
146
|
+
// WORD BOUNDARIES, not `String.includes`. A bare substring test made `test` match `latest`
|
|
147
|
+
// and `fastest`, and `spec` match `specific` and `respect` — so "ship the latest build"
|
|
148
|
+
// was filed under "Test style". Each list is compiled once into one alternation with
|
|
149
|
+
// boundaries on both sides.
|
|
150
|
+
|
|
151
|
+
const TEST_KEYWORDS = ['test', 'tests', 'testing', 'spec', 'specs', 'jest', 'vitest', 'mocha', 'assert', 'assertion', 'assertions', 'coverage', 'tdd'];
|
|
152
|
+
const COMMIT_KEYWORDS = ['commit', 'commits', 'committed', 'pull request', 'pull requests', 'pr', 'prs', 'changelog', 'conventional commit'];
|
|
153
|
+
/**
|
|
154
|
+
* Positive evidence that an observation really is about a language/idiom preference. Before
|
|
155
|
+
* this list `languages` was a fallback, so it collected everything; now it has to be EARNED,
|
|
156
|
+
* and what does not earn it lands in `unclassified`.
|
|
157
|
+
*/
|
|
158
|
+
const LANGUAGE_KEYWORDS = [
|
|
159
|
+
'typescript', 'javascript', 'esm', 'cjs', 'mjs', 'commonjs', 'python', 'rust', 'golang', 'bash', 'powershell', 'sql', 'css', 'html', 'jsx', 'tsx', 'react', 'node', 'npm',
|
|
160
|
+
'idiom', 'idioms', 'syntax', 'lint', 'linting', 'eslint', 'prettier', 'formatting', 'indentation', 'naming convention', 'naming conventions',
|
|
161
|
+
'arrow function', 'arrow functions', 'async', 'await', 'strict mode', 'typed', 'typing', 'interface', 'interfaces', 'generics', 'import', 'imports', 'module', 'modules',
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
function keywordMatcher(keywords) {
|
|
165
|
+
const alternation = keywords
|
|
166
|
+
.slice()
|
|
167
|
+
.sort((a, b) => b.length - a.length)
|
|
168
|
+
.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
169
|
+
.join('|');
|
|
170
|
+
return new RegExp(`\\b(?:${alternation})\\b`, 'i');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const TEST_RE = keywordMatcher(TEST_KEYWORDS);
|
|
174
|
+
const COMMIT_RE = keywordMatcher(COMMIT_KEYWORDS);
|
|
175
|
+
const LANGUAGE_RE = keywordMatcher(LANGUAGE_KEYWORDS);
|
|
176
|
+
|
|
177
|
+
// ── paths ─────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** Mirrors DR1's `defaultObservationsPath` pattern — the profile file's canonical location. */
|
|
180
|
+
export function defaultProfilePath(repoRoot) {
|
|
181
|
+
return join(repoRoot, '.heyspex', 'spexcode-profile.md');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The incremental-mark file's canonical location — separate from DR1's own `.heyspex/doctrine/state.json`. */
|
|
185
|
+
export function defaultProfileStatePath(repoRoot) {
|
|
186
|
+
return join(repoRoot, '.heyspex', 'profile-state.json');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── section classification (heuristic, no model call) ───────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Classify one observation into a profile section key. `approval`/`refusal` observations
|
|
193
|
+
* feed "tools approved or refused"; `correction` observations feed "recurring corrections"
|
|
194
|
+
* (each correction DR1 extracted already represents a correction event); everything else is
|
|
195
|
+
* classified by WORD-BOUNDARY keyword matching over `text`.
|
|
196
|
+
*
|
|
197
|
+
* There is no fallback section. An observation matching no keyword list is `unclassified` —
|
|
198
|
+
* see {@link SECTION_ORDER}. It is still kept and still cited; it is simply not asserted to
|
|
199
|
+
* be a language preference, a test-style rule or a commit convention when nothing in it says
|
|
200
|
+
* so.
|
|
201
|
+
*
|
|
202
|
+
* @param {{ kind?: string, text?: string }} obs
|
|
203
|
+
* @returns {'languages'|'tests'|'commits'|'tools'|'corrections'|'unclassified'}
|
|
204
|
+
*/
|
|
205
|
+
export function classifySection(obs) {
|
|
206
|
+
if (obs.kind === 'approval' || obs.kind === 'refusal') return 'tools';
|
|
207
|
+
if (obs.kind === 'correction') return 'corrections';
|
|
208
|
+
const text = String(obs.text ?? '');
|
|
209
|
+
if (TEST_RE.test(text)) return 'tests';
|
|
210
|
+
if (COMMIT_RE.test(text)) return 'commits';
|
|
211
|
+
if (LANGUAGE_RE.test(text)) return 'languages';
|
|
212
|
+
return 'unclassified';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── dedupe (the Profile path's own defect) ────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// doctrine/merge.mjs dedupes principles; the Profile path never did, so identical text was
|
|
218
|
+
// appended once per occurrence — `permission mode: bypassPermissions` alone accounted for
|
|
219
|
+
// 3,996 of the 5,328 observations measured on this machine, and every one of them became its
|
|
220
|
+
// own profile line, evicting genuine habits under MAX_ENTRIES_PER_SECTION.
|
|
221
|
+
//
|
|
222
|
+
// Applied in `cloneSections`, so it protects the STORED `profile-state.json` as well as the
|
|
223
|
+
// rendered digest — a state file written by an older build is collapsed the first time it is
|
|
224
|
+
// read, exactly like the size caps above.
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Normalized dedupe key: casing-, punctuation- and whitespace-insensitive. Shared with
|
|
228
|
+
* proposals.mjs's near-duplicate detection, which imports it from here — one fingerprint
|
|
229
|
+
* definition, so "already in the profile" and "already proposed" can never disagree.
|
|
230
|
+
* @param {unknown} text
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
export function normalizeFingerprint(text) {
|
|
234
|
+
return String(text ?? '')
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Collapse entries sharing a {@link normalizeFingerprint}, keeping the MOST RECENT occurrence
|
|
243
|
+
* (and therefore its citation) at the most-recent position — so a survivor is never evicted
|
|
244
|
+
* by {@link trimSection} on account of where its first duplicate happened to sit.
|
|
245
|
+
* @param {Array<{ text?: string }>} entries
|
|
246
|
+
* @returns {Array<object>}
|
|
247
|
+
*/
|
|
248
|
+
export function dedupeSection(entries) {
|
|
249
|
+
const byFingerprint = new Map();
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
const fingerprint = normalizeFingerprint(entry?.text);
|
|
252
|
+
if (byFingerprint.has(fingerprint)) byFingerprint.delete(fingerprint);
|
|
253
|
+
byFingerprint.set(fingerprint, entry);
|
|
254
|
+
}
|
|
255
|
+
return [...byFingerprint.values()];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── section-entry state (the structured, append-friendly store) ─────────────────────
|
|
259
|
+
|
|
260
|
+
function emptySections() {
|
|
261
|
+
const out = {};
|
|
262
|
+
for (const key of SECTION_KEYS) out[key] = [];
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cloneSections(sections) {
|
|
267
|
+
const out = emptySections();
|
|
268
|
+
if (!sections || typeof sections !== 'object') return out;
|
|
269
|
+
for (const key of SECTION_KEYS) {
|
|
270
|
+
// Entries are clamped and trimmed HERE as well as at fold time, so a state file written
|
|
271
|
+
// by an older, uncapped build is normalized the first time it is read instead of
|
|
272
|
+
// re-rendering its oversized entries forever. This preserves the incremental
|
|
273
|
+
// "re-renders byte-identical" contract in the only way that stays true going forward:
|
|
274
|
+
// every stored entry is already within the caps, so re-rendering it is a no-op.
|
|
275
|
+
const entries = Array.isArray(sections[key]) ? sections[key] : [];
|
|
276
|
+
out[key] = trimSection(dedupeSection(entries.map((e) => ({ ...e, text: clampEntryText(e?.text) }))));
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function emptyProfileState() {
|
|
282
|
+
return { lastObservationSeq: 0, lastLearnedAt: null, sections: emptySections() };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── incremental state persistence ────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/** Read the profile incremental-state file; missing/corrupt → a fresh empty state. */
|
|
288
|
+
export function loadProfileState(path) {
|
|
289
|
+
if (!existsSync(path)) return emptyProfileState();
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
292
|
+
if (!parsed || typeof parsed !== 'object') return emptyProfileState();
|
|
293
|
+
return {
|
|
294
|
+
lastObservationSeq: Number.isFinite(parsed.lastObservationSeq) ? parsed.lastObservationSeq : 0,
|
|
295
|
+
lastLearnedAt: typeof parsed.lastLearnedAt === 'string' ? parsed.lastLearnedAt : null,
|
|
296
|
+
sections: cloneSections(parsed.sections),
|
|
297
|
+
};
|
|
298
|
+
} catch {
|
|
299
|
+
return emptyProfileState();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Persist the profile incremental-state file. */
|
|
304
|
+
export function saveProfileState(path, state) {
|
|
305
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
306
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* One profile line for a section entry — carries its session citation
|
|
313
|
+
* (`source`, `project`, `session`, `ts`) exactly as DR1's corpus.mjs recorded it on the
|
|
314
|
+
* observation. Never re-derived or guessed: every field is read straight off the
|
|
315
|
+
* `DoctrineObservation` this entry was built from.
|
|
316
|
+
*
|
|
317
|
+
* @param {{ text: string, source: string, project: string, session: string, ts: string|null }} entry
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
export function citationLine(entry) {
|
|
321
|
+
return `- ${entry.text} _(source: ${entry.source}, project: ${entry.project}, session: ${entry.session}, ts: ${entry.ts ?? 'null'})_`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Render the full profile markdown from a `sections` store (the same shape `buildProfile`
|
|
326
|
+
* carries on its returned `state.sections`). One heading per non-empty section, in
|
|
327
|
+
* {@link SECTION_ORDER}; a section with no entries is omitted entirely rather than printed
|
|
328
|
+
* with a "nothing yet" placeholder. Pure — no I/O.
|
|
329
|
+
*
|
|
330
|
+
* @param {Record<string, Array<{ text: string, source: string, project: string, session: string, ts: string|null }>>} sections
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
export function renderProfileMarkdown(sections) {
|
|
334
|
+
const lines = [
|
|
335
|
+
'# SpexCode Profile',
|
|
336
|
+
'',
|
|
337
|
+
'Machine-local, heuristic v0 — never written to the vault. Every line cites the session it came from.',
|
|
338
|
+
'',
|
|
339
|
+
];
|
|
340
|
+
for (const { key, heading } of SECTION_ORDER) {
|
|
341
|
+
const entries = sections?.[key] ?? [];
|
|
342
|
+
if (entries.length === 0) continue;
|
|
343
|
+
lines.push(`## ${heading}`, '');
|
|
344
|
+
for (const entry of entries) lines.push(citationLine(entry));
|
|
345
|
+
lines.push('');
|
|
346
|
+
}
|
|
347
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Render the INJECTED profile digest — what actually rides a seat's context — from the same
|
|
352
|
+
* structured `sections` store {@link renderProfileMarkdown} renders the full file from.
|
|
353
|
+
*
|
|
354
|
+
* Distinct from `renderProfileMarkdown` on purpose: that one renders the complete, browsable
|
|
355
|
+
* record to `.heyspex/spexcode-profile.md`, and is allowed to be long. This one is the
|
|
356
|
+
* bounded excerpt, and it allocates the budget FAIRLY ACROSS SECTIONS rather than filling it
|
|
357
|
+
* from the top. A plain top-down cut of the rendered file spends the entire budget inside
|
|
358
|
+
* "Preferred languages and idioms" (the first and largest section) and silently starves
|
|
359
|
+
* "Recurring corrections" and "Tools approved or refused" — the two sections whose whole
|
|
360
|
+
* point is to change how a seat behaves.
|
|
361
|
+
*
|
|
362
|
+
* Selection within a section is most-recent-first, since a developer's latest correction
|
|
363
|
+
* describes them better than their oldest one. Round-robin across sections then takes one
|
|
364
|
+
* entry from each in turn, so a section is never crowded out by a wordier neighbour.
|
|
365
|
+
*
|
|
366
|
+
* @param {Record<string, Array<{ text: string, source: string, project: string, session: string, ts: string|null }>>} sections
|
|
367
|
+
* @param {{ maxTokens?: number, estimate?: (text: string) => number }} [opts]
|
|
368
|
+
* @returns {{ text: string, includedCount: number, totalCount: number, truncated: boolean }}
|
|
369
|
+
*/
|
|
370
|
+
export function renderProfileDigest(sections, { maxTokens = PROFILE_MAX_TOKENS, estimate } = {}) {
|
|
371
|
+
const sizeOf = typeof estimate === 'function' ? estimate : (t) => Math.ceil(String(t).length / 4);
|
|
372
|
+
const budget = Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : Infinity;
|
|
373
|
+
|
|
374
|
+
const header = '# SpexCode Profile';
|
|
375
|
+
const subtitle = 'Machine-local, heuristic v0 — never written to the vault. Every line cites the session it came from.';
|
|
376
|
+
|
|
377
|
+
// Most-recent-first queues, one per section, plus the running total for the footer.
|
|
378
|
+
const queues = new Map();
|
|
379
|
+
let totalCount = 0;
|
|
380
|
+
for (const { key } of SECTION_ORDER) {
|
|
381
|
+
const entries = Array.isArray(sections?.[key]) ? [...sections[key]].reverse() : [];
|
|
382
|
+
totalCount += entries.length;
|
|
383
|
+
if (entries.length > 0) queues.set(key, entries);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Reserve the footer's own cost up front, so the digest always comes in AT OR UNDER
|
|
387
|
+
// `budget` INCLUDING its footer. Without the reservation the footer is the one line the
|
|
388
|
+
// outer clamp in session-bootstrap.mjs's `renderProfileBlock` trims off — replacing the
|
|
389
|
+
// informative "N of M entries shown" note with its own blunter one.
|
|
390
|
+
const footerReserve = totalCount > 0 ? sizeOf(`_(${totalCount} of ${totalCount} profile entries shown — bounded to a ${budget}-token injection budget.)_`) : 0;
|
|
391
|
+
let used = sizeOf(header) + sizeOf(subtitle) + footerReserve;
|
|
392
|
+
const picked = new Map(); // key -> lines, in the order they were selected
|
|
393
|
+
let includedCount = 0;
|
|
394
|
+
let truncated = false;
|
|
395
|
+
|
|
396
|
+
// Round-robin: one entry per section per pass, until the budget is spent or nothing is left.
|
|
397
|
+
while (queues.size > 0) {
|
|
398
|
+
let progressed = false;
|
|
399
|
+
for (const { key, heading } of SECTION_ORDER) {
|
|
400
|
+
const queue = queues.get(key);
|
|
401
|
+
if (!queue || queue.length === 0) continue;
|
|
402
|
+
const line = citationLine(queue[0]);
|
|
403
|
+
const needsHeading = !picked.has(key);
|
|
404
|
+
const cost = sizeOf(line) + (needsHeading ? sizeOf(`## ${heading}`) : 0);
|
|
405
|
+
if (used + cost > budget) {
|
|
406
|
+
truncated = true;
|
|
407
|
+
queues.delete(key);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
queue.shift();
|
|
411
|
+
used += cost;
|
|
412
|
+
if (needsHeading) picked.set(key, []);
|
|
413
|
+
picked.get(key).push(line);
|
|
414
|
+
includedCount++;
|
|
415
|
+
progressed = true;
|
|
416
|
+
if (queue.length === 0) queues.delete(key);
|
|
417
|
+
}
|
|
418
|
+
if (!progressed) break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const lines = [header, '', subtitle, ''];
|
|
422
|
+
for (const { key, heading } of SECTION_ORDER) {
|
|
423
|
+
const got = picked.get(key);
|
|
424
|
+
if (!got || got.length === 0) continue;
|
|
425
|
+
lines.push(`## ${heading}`, '', ...got, '');
|
|
426
|
+
}
|
|
427
|
+
if (truncated) {
|
|
428
|
+
lines.push(`_(${includedCount} of ${totalCount} profile entries shown — bounded to a ${budget}-token injection budget.)_`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { text: lines.join('\n').replace(/\n+$/, ''), includedCount, totalCount, truncated };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── context-budget registration (P2 scope item 3 — measurement only, no injection here) ──
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Register the profile block's size against the harness's single context-injection
|
|
438
|
+
* accounting path ({@link recordContextInjection}), the same pattern DR3 uses for the
|
|
439
|
+
* Doctrine block. This function only measures and records — actually injecting the
|
|
440
|
+
* profile into a seat's context is a later task (epic scope item 4, "Conditioning").
|
|
441
|
+
*
|
|
442
|
+
* @param {ReturnType<import('../session-record.mjs').createSessionRecord>} record
|
|
443
|
+
* @param {string} markdown
|
|
444
|
+
* @param {{ source?: string }} [opts]
|
|
445
|
+
* @returns {import('../context-budget.mjs').ContextInjectionMeasurement}
|
|
446
|
+
*/
|
|
447
|
+
export function registerProfileContextUsage(record, markdown, { source = 'spexcode-profile' } = {}) {
|
|
448
|
+
return recordContextInjection(record, { source, text: markdown });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── top-level entrypoint ────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Build (or incrementally extend) the developer's SpexCode Profile from this machine's
|
|
455
|
+
* Doctrine observation stream, and write it to `outPath`.
|
|
456
|
+
*
|
|
457
|
+
* State resolution order: an explicit `state` object wins; else, when `statePath` is
|
|
458
|
+
* given, it is loaded from disk; else a fresh empty state (a first/cold run). When
|
|
459
|
+
* `statePath` is given, the resulting state is also persisted back to it — a caller that
|
|
460
|
+
* wants a purely in-memory two-call test (no disk state file) passes `state` explicitly
|
|
461
|
+
* instead and threads the returned `state` into the next call itself.
|
|
462
|
+
*
|
|
463
|
+
* @param {{
|
|
464
|
+
* observationsPath: string,
|
|
465
|
+
* outPath: string,
|
|
466
|
+
* statePath?: string|null,
|
|
467
|
+
* state?: { lastObservationSeq: number, lastLearnedAt: string|null, sections: object }|null,
|
|
468
|
+
* maxObservations?: number,
|
|
469
|
+
* now?: () => string,
|
|
470
|
+
* }} opts
|
|
471
|
+
* @returns {{
|
|
472
|
+
* profilePath: string,
|
|
473
|
+
* markdown: string,
|
|
474
|
+
* state: object,
|
|
475
|
+
* observationsProcessed: number,
|
|
476
|
+
* observationsDeferred: number,
|
|
477
|
+
* }}
|
|
478
|
+
*/
|
|
479
|
+
export function buildProfile({
|
|
480
|
+
observationsPath,
|
|
481
|
+
outPath,
|
|
482
|
+
statePath = null,
|
|
483
|
+
state = null,
|
|
484
|
+
maxObservations = DEFAULT_MAX_OBSERVATIONS,
|
|
485
|
+
now = () => new Date().toISOString(),
|
|
486
|
+
} = {}) {
|
|
487
|
+
if (!observationsPath) throw new Error('buildProfile: observationsPath is required');
|
|
488
|
+
if (!outPath) throw new Error('buildProfile: outPath is required');
|
|
489
|
+
|
|
490
|
+
const priorState = state ?? (statePath ? loadProfileState(statePath) : null) ?? emptyProfileState();
|
|
491
|
+
const sections = cloneSections(priorState.sections);
|
|
492
|
+
const startIndex = Number.isFinite(priorState.lastObservationSeq) ? priorState.lastObservationSeq : 0;
|
|
493
|
+
const cap = Number.isFinite(maxObservations) && maxObservations > 0 ? maxObservations : Infinity;
|
|
494
|
+
|
|
495
|
+
const allObservations = readObservations(observationsPath);
|
|
496
|
+
|
|
497
|
+
let processed = 0;
|
|
498
|
+
let index = startIndex;
|
|
499
|
+
for (; index < allObservations.length; index++) {
|
|
500
|
+
if (processed >= cap) break;
|
|
501
|
+
const obs = allObservations[index];
|
|
502
|
+
if (!obs || SKIPPED_KINDS.has(obs.kind)) continue;
|
|
503
|
+
const key = classifySection(obs);
|
|
504
|
+
sections[key].push({
|
|
505
|
+
text: clampEntryText(obs.text),
|
|
506
|
+
source: obs.source,
|
|
507
|
+
project: obs.project,
|
|
508
|
+
session: obs.session,
|
|
509
|
+
ts: obs.ts ?? null,
|
|
510
|
+
});
|
|
511
|
+
sections[key] = trimSection(dedupeSection(sections[key]));
|
|
512
|
+
processed++;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const observationsDeferred = Math.max(0, allObservations.length - index);
|
|
516
|
+
const newState = { lastObservationSeq: index, lastLearnedAt: now(), sections };
|
|
517
|
+
const markdown = renderProfileMarkdown(sections);
|
|
518
|
+
|
|
519
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
520
|
+
writeFileSync(outPath, markdown);
|
|
521
|
+
if (statePath) saveProfileState(statePath, newState);
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
profilePath: outPath,
|
|
525
|
+
markdown,
|
|
526
|
+
state: newState,
|
|
527
|
+
observationsProcessed: processed,
|
|
528
|
+
observationsDeferred,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export default {
|
|
533
|
+
DEFAULT_MAX_OBSERVATIONS,
|
|
534
|
+
MAX_ENTRY_CHARS,
|
|
535
|
+
MAX_ENTRIES_PER_SECTION,
|
|
536
|
+
PROFILE_MAX_TOKENS,
|
|
537
|
+
clampEntryText,
|
|
538
|
+
SECTION_ORDER,
|
|
539
|
+
classifySection,
|
|
540
|
+
normalizeFingerprint,
|
|
541
|
+
dedupeSection,
|
|
542
|
+
loadProfileState,
|
|
543
|
+
saveProfileState,
|
|
544
|
+
citationLine,
|
|
545
|
+
renderProfileMarkdown,
|
|
546
|
+
renderProfileDigest,
|
|
547
|
+
registerProfileContextUsage,
|
|
548
|
+
buildProfile,
|
|
549
|
+
defaultProfilePath,
|
|
550
|
+
defaultProfileStatePath,
|
|
551
|
+
};
|