cap-pro 1.0.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/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
// @cap-context CAP F-072 Compute Two-Layer Fitness Score — drives F-074 Pattern Unlearn. Pure-compute,
|
|
2
|
+
// deterministic, zero external deps. Reads only via cap-pattern-pipeline.listPatterns and
|
|
3
|
+
// cap-learning-signals.getSignals; writes per-pattern fitness records and append-only
|
|
4
|
+
// apply-snapshot JSONL under .cap/learning/fitness/. Layer 1 is a short-term Override
|
|
5
|
+
// *count* over the most-recently-observed sessionId; Layer 2 is a long-term per-session
|
|
6
|
+
// weighted average that activates at n >= 5 active sessions.
|
|
7
|
+
// @cap-decision(F-072/D1) AC-1 metric is a COUNT, not a rate. The number of override records in the most
|
|
8
|
+
// recent session whose evidence.candidateId matches the pattern's evidence.candidateId.
|
|
9
|
+
// Fallback path (defensive): when the pattern's evidence carries no candidateId, fall back
|
|
10
|
+
// to featureRef matching against the override record's featureId. Locked by user direction.
|
|
11
|
+
// @cap-decision(F-072/D2) AC-2 norm = active-session count (per-session average). Layer 2 activates at
|
|
12
|
+
// n >= 5; below that, the value is still computed (AC-5 requires the data exists from day
|
|
13
|
+
// one) and ready=false signals "do not display yet". Locked by user direction.
|
|
14
|
+
// @cap-decision(F-072/D3) "Pattern was active in session" = at least one signal in that session matches
|
|
15
|
+
// ONE of: evidence.candidateId on an override; OR evidence.candidateId on a regret; OR a
|
|
16
|
+
// memoryFileHash that the pattern references (memory-ref signals). Pinned definition;
|
|
17
|
+
// used by both Layer-2 norm and the AC-4 expired-after-20-sessions check.
|
|
18
|
+
// @cap-decision(F-072/D4) "Last session" for AC-1 = the most-recent sessionId observed in the override
|
|
19
|
+
// JSONL — NOT a wall-clock cut-off. Determinism (AC-7) requires no time-based gates.
|
|
20
|
+
// "Most-recent" is computed deterministically: the override record with the maximum
|
|
21
|
+
// ts string (lexicographic ISO-8601 sort) wins; ties resolved by the record's id field.
|
|
22
|
+
// @cap-decision(F-072/D5) Apply-snapshots are APPEND-ONLY (.snapshots.jsonl). Each call to
|
|
23
|
+
// recordApplySnapshot appends a fresh line — multiple applies in the same session
|
|
24
|
+
// produce multiple lines. F-074 reads the tail to compare pre-apply vs post-apply.
|
|
25
|
+
// AC-3 "Rolling-30-Sessions AND Lifetime aggregates simultaneously" is satisfied by
|
|
26
|
+
// splitting the two responsibilities: the canonical .json record IS the lifetime
|
|
27
|
+
// aggregate (cumulative across all sessions); the .snapshots.jsonl IS the rolling
|
|
28
|
+
// sequence (one line per apply event). F-074 / F-073 read both. User-confirmed
|
|
29
|
+
// before ship; an alternative (compute a 30-session rolling window inside the
|
|
30
|
+
// canonical record) was considered and rejected because it would double the formula
|
|
31
|
+
// surface and require a deterministic "last 30" cut-off without a clear use case yet.
|
|
32
|
+
// @cap-decision(F-072/D6) AC-7 zero-deps + deterministic. Wherever Sets/Maps drive iteration we sort
|
|
33
|
+
// the keys before consuming them. randomBytes / Date.now / Math.random are forbidden
|
|
34
|
+
// inside the score formulas (the persisted ts is the ONLY allowed time source, and it
|
|
35
|
+
// enters via options.now → never the formula).
|
|
36
|
+
// @cap-constraint Zero external dependencies: node:fs + node:path only. We never read overrides.jsonl /
|
|
37
|
+
// memory-refs.jsonl / regrets.jsonl directly — always via cap-learning-signals.getSignals.
|
|
38
|
+
// We never read pattern files directly — always via cap-pattern-pipeline.listPatterns.
|
|
39
|
+
// Single source of truth for both queries. cap-telemetry.hashContext is available if a
|
|
40
|
+
// hash is ever needed, but this module currently doesn't need one.
|
|
41
|
+
// @cap-risk(F-072/AC-7) DETERMINISM BOUNDARY — every code path that uses Set/Map for iteration MUST
|
|
42
|
+
// sort keys before iterating. Every code path that touches time MUST route through
|
|
43
|
+
// options.now and never let the timestamp affect the formula. The adversarial test
|
|
44
|
+
// runs computeFitness 10x and shuffles signal-record ordering to assert byte-level
|
|
45
|
+
// equality of the resulting FitnessRecord.
|
|
46
|
+
|
|
47
|
+
'use strict';
|
|
48
|
+
|
|
49
|
+
// @cap-feature(feature:F-072, primary:true) Compute Two-Layer Fitness Score — short-term override-count
|
|
50
|
+
// + long-term weighted memory-ref/regret average per pattern.
|
|
51
|
+
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
|
|
55
|
+
const learningSignals = require('./cap-learning-signals.cjs');
|
|
56
|
+
const patternPipeline = require('./cap-pattern-pipeline.cjs');
|
|
57
|
+
|
|
58
|
+
// -----------------------------------------------------------------------------
|
|
59
|
+
// Constants — top-of-file so consumers (F-073, F-074, /cap:learn) and tests
|
|
60
|
+
// reference exactly one place. Mirrors cap-pattern-pipeline.cjs layout.
|
|
61
|
+
// -----------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const CAP_DIR = '.cap';
|
|
64
|
+
const LEARNING_DIR = 'learning';
|
|
65
|
+
const FITNESS_DIR = 'fitness';
|
|
66
|
+
|
|
67
|
+
// AC-2: Layer 2 activates at n >= 5 active sessions. Below that, ready=false but the
|
|
68
|
+
// value is still computed and persisted (AC-5).
|
|
69
|
+
const LAYER2_READY_THRESHOLD = 5;
|
|
70
|
+
|
|
71
|
+
// AC-2 weights. memory-ref signals (positive — "this pattern's territory was useful")
|
|
72
|
+
// count as 1; regret signals (negative — "we now wish we'd done differently") count as 2.
|
|
73
|
+
// Locked top-of-file so a future tuning lives in one place and the adversarial tests can
|
|
74
|
+
// verify exact behaviour.
|
|
75
|
+
const WEIGHT_MEMORY_REF = 1;
|
|
76
|
+
const WEIGHT_REGRET = 2;
|
|
77
|
+
|
|
78
|
+
// AC-4: a pattern that has been observed in zero sessions over the last EXPIRY_SESSIONS
|
|
79
|
+
// sessions worth of signals is auto-marked expired. The "last 20 sessions" window is
|
|
80
|
+
// computed from the union of session ids across the three signal types — NOT from a
|
|
81
|
+
// wall-clock window (D6 forbids time-based gates inside the formulas).
|
|
82
|
+
const EXPIRY_SESSIONS = 20;
|
|
83
|
+
|
|
84
|
+
// File-name shapes.
|
|
85
|
+
const FITNESS_JSON_SUFFIX = '.json';
|
|
86
|
+
const FITNESS_SNAPSHOTS_SUFFIX = '.snapshots.jsonl';
|
|
87
|
+
|
|
88
|
+
// Pattern-id format mirror — duplicated here only for the regex; the canonical
|
|
89
|
+
// allocator lives in cap-pattern-pipeline.cjs.
|
|
90
|
+
const PATTERN_ID_RE = /^P-\d+$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {Object} FitnessLayer1
|
|
94
|
+
* @property {'override-count'} kind
|
|
95
|
+
* @property {number} value - Number of overrides in the most-recent session whose evidence.candidateId matches the pattern.
|
|
96
|
+
* @property {string|null} lastSessionId - Most-recent sessionId observed in the override corpus (D4).
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {Object} FitnessLayer2
|
|
101
|
+
* @property {'weighted-average'} kind
|
|
102
|
+
* @property {number} value - (memoryRefs * WEIGHT_MEMORY_REF + regrets * WEIGHT_REGRET) / activeSessions, or 0 when n=0.
|
|
103
|
+
* @property {number} n - Active-session count for this pattern (across all signal types — see D3).
|
|
104
|
+
* @property {boolean} ready - True iff n >= LAYER2_READY_THRESHOLD.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {Object} FitnessRecord
|
|
109
|
+
* @property {string} id - Mirrors patternId for back-compat with the F-071 PatternRecord shape.
|
|
110
|
+
* @property {string} patternId - 'P-NNN'.
|
|
111
|
+
* @property {string} ts - ISO timestamp at which this record was persisted (NOT used in any formula).
|
|
112
|
+
* @property {FitnessLayer1} layer1
|
|
113
|
+
* @property {FitnessLayer2} layer2
|
|
114
|
+
* @property {number} activeSessions - Same as layer2.n; surfaced top-level for convenience.
|
|
115
|
+
* @property {string|null} lastSeenSessionId - Most-recent sessionId in which this pattern was active.
|
|
116
|
+
* @property {string|null} lastSeenAt - ISO timestamp of the most-recent matching signal.
|
|
117
|
+
* @property {boolean} expired - AC-4 marker; set by markExpired or runFitnessPass.
|
|
118
|
+
* @property {{candidateId: string|null, featureRef: string|null}} evidence - Pinned identity used to match signals (D1).
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @typedef {Object} SnapshotRecord
|
|
123
|
+
* @property {string} ts - ISO timestamp at apply-time (D5).
|
|
124
|
+
* @property {string} patternId
|
|
125
|
+
* @property {FitnessLayer1} layer1
|
|
126
|
+
* @property {FitnessLayer2} layer2
|
|
127
|
+
* @property {number} n
|
|
128
|
+
* @property {string[]} activeSessionsList - SORTED list of sessionIds in which the pattern was active. Sorted lock matches D6.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
// -----------------------------------------------------------------------------
|
|
132
|
+
// Internal helpers — directory + IO
|
|
133
|
+
// -----------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function ensureDir(dir) {
|
|
136
|
+
try {
|
|
137
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
} catch (_e) {
|
|
139
|
+
// Public boundary callers swallow errors; the next write will surface persistent IO problems.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function learningRoot(projectRoot) {
|
|
144
|
+
return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fitnessDir(projectRoot) {
|
|
148
|
+
return path.join(learningRoot(projectRoot), FITNESS_DIR);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fitnessFilePath(projectRoot, patternId) {
|
|
152
|
+
return path.join(fitnessDir(projectRoot), `${patternId}${FITNESS_JSON_SUFFIX}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function snapshotsFilePath(projectRoot, patternId) {
|
|
156
|
+
return path.join(fitnessDir(projectRoot), `${patternId}${FITNESS_SNAPSHOTS_SUFFIX}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate a P-NNN id. Rejects anything else defensively — every public function
|
|
161
|
+
* routes through this gate so a hostile or malformed id can never become a path.
|
|
162
|
+
*
|
|
163
|
+
* @param {any} id
|
|
164
|
+
* @returns {boolean}
|
|
165
|
+
*/
|
|
166
|
+
function isValidPatternId(id) {
|
|
167
|
+
return typeof id === 'string' && PATTERN_ID_RE.test(id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Look up the persisted PatternRecord with the given id. Returns null when the
|
|
172
|
+
* pattern is not found (or the patterns directory is missing). Never throws —
|
|
173
|
+
* F-074 will call this on potentially-deleted ids and must get a clean null.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} projectRoot
|
|
176
|
+
* @param {string} patternId
|
|
177
|
+
* @returns {object|null}
|
|
178
|
+
*/
|
|
179
|
+
function findPattern(projectRoot, patternId) {
|
|
180
|
+
try {
|
|
181
|
+
const all = patternPipeline.listPatterns(projectRoot);
|
|
182
|
+
if (!Array.isArray(all)) return null;
|
|
183
|
+
for (const p of all) {
|
|
184
|
+
if (p && p.id === patternId) return p;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
} catch (_e) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -----------------------------------------------------------------------------
|
|
193
|
+
// Signal matching — turns a PatternRecord + a signal record into a boolean.
|
|
194
|
+
// D1, D3 live here.
|
|
195
|
+
// -----------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extract the candidateId the pattern is anchored to, if any. F-071's PatternRecord
|
|
199
|
+
* stores it under evidence.candidateId. Anything else (legacy or hand-written
|
|
200
|
+
* patterns) falls back to null and we use featureRef as the matcher (D1).
|
|
201
|
+
*
|
|
202
|
+
* @param {object} pattern
|
|
203
|
+
* @returns {string|null}
|
|
204
|
+
*/
|
|
205
|
+
function patternCandidateId(pattern) {
|
|
206
|
+
if (!pattern || typeof pattern !== 'object') return null;
|
|
207
|
+
const ev = pattern.evidence;
|
|
208
|
+
if (ev && typeof ev === 'object' && typeof ev.candidateId === 'string' && ev.candidateId.length > 0) {
|
|
209
|
+
return ev.candidateId;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Extract the featureRef the pattern targets. Used as the fallback matcher when
|
|
216
|
+
* the pattern has no candidateId (D1). Defensive: only accept exact F-NNN shape.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} pattern
|
|
219
|
+
* @returns {string|null}
|
|
220
|
+
*/
|
|
221
|
+
function patternFeatureRef(pattern) {
|
|
222
|
+
if (!pattern || typeof pattern !== 'object') return null;
|
|
223
|
+
if (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef)) {
|
|
224
|
+
return pattern.featureRef;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Memory-file-hash anchors the pattern carries — a future LLM-stage pattern may
|
|
231
|
+
* reference one or more memory files via evidence.memoryFileHashes[]. We accept
|
|
232
|
+
* either a single string or an array; missing → empty array. D3.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} pattern
|
|
235
|
+
* @returns {string[]}
|
|
236
|
+
*/
|
|
237
|
+
function patternMemoryFileHashes(pattern) {
|
|
238
|
+
if (!pattern || typeof pattern !== 'object') return [];
|
|
239
|
+
const ev = pattern.evidence;
|
|
240
|
+
if (!ev || typeof ev !== 'object') return [];
|
|
241
|
+
const raw = ev.memoryFileHashes != null ? ev.memoryFileHashes : ev.memoryFileHash;
|
|
242
|
+
if (!raw) return [];
|
|
243
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const h of arr) {
|
|
246
|
+
if (typeof h === 'string' && h.length > 0) out.push(h);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Decision-id anchors for regrets (D3). F-070 regrets carry decisionId; a future
|
|
253
|
+
* LLM-stage pattern may reference specific decisionIds via evidence.decisionIds[].
|
|
254
|
+
*
|
|
255
|
+
* @param {object} pattern
|
|
256
|
+
* @returns {string[]}
|
|
257
|
+
*/
|
|
258
|
+
function patternDecisionIds(pattern) {
|
|
259
|
+
if (!pattern || typeof pattern !== 'object') return [];
|
|
260
|
+
const ev = pattern.evidence;
|
|
261
|
+
if (!ev || typeof ev !== 'object') return [];
|
|
262
|
+
const raw = ev.decisionIds != null ? ev.decisionIds : ev.decisionId;
|
|
263
|
+
if (!raw) return [];
|
|
264
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const d of arr) {
|
|
267
|
+
if (typeof d === 'string' && d.length > 0) out.push(d);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Does an OVERRIDE record match this pattern? D1 primary path: candidateId match.
|
|
274
|
+
* Defensive fallback: when the pattern carries no candidateId we accept featureRef
|
|
275
|
+
* matches. AC-7 demands this be deterministic — no time considerations.
|
|
276
|
+
*
|
|
277
|
+
* The override record's `evidence` field doesn't exist in F-070's record schema; the
|
|
278
|
+
* pattern's evidence.candidateId is matched against an override-derived candidate
|
|
279
|
+
* key. Since F-071 builds candidateId via telemetry.hashContext(token) where token
|
|
280
|
+
* encodes (signalType, featureId, contextKey), and the override record carries the
|
|
281
|
+
* same fields, we reconstruct the same key here and compare. Re-using F-071's
|
|
282
|
+
* exact hashing primitive is over-engineering for the test surface — instead we
|
|
283
|
+
* match on the structured fields directly: {signalType=override, featureId, targetFileHash}.
|
|
284
|
+
* This stays robust if F-071's hashing changes.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} record
|
|
287
|
+
* @param {string|null} candidateId - Pattern's candidateId, if any.
|
|
288
|
+
* @param {string|null} featureRef - Pattern's featureRef, if any.
|
|
289
|
+
* @param {string|null} candidateFeatureId - Pattern's evidence-derived featureId (mirrors how F-071
|
|
290
|
+
* built the candidate). When the pattern has a candidateId we still need its featureId to match
|
|
291
|
+
* the override; we get it from pattern.evidence.featureId or fall back to featureRef.
|
|
292
|
+
* @param {string|null} candidateContextHash - Pattern's evidence contextHash (the "topContextHashes[0].hash"
|
|
293
|
+
* F-071 persists). When present, prefer matching override.contextHash exactly — that's the strict identity.
|
|
294
|
+
*
|
|
295
|
+
* **Silent-zero edge case (debugging hint):** when `candidateId` is set BUT both
|
|
296
|
+
* `candidateFeatureId` AND `featureRef` are null, this matcher returns false for every
|
|
297
|
+
* override — there is no anchor to compare against. The result looks like "no overrides
|
|
298
|
+
* match this pattern" without an obvious cause. The condition is unreachable in normal
|
|
299
|
+
* flows (F-071 always populates either evidence.featureId or featureRef on a promoted
|
|
300
|
+
* pattern), but a malformed legacy / hand-edited pattern record can land in this branch.
|
|
301
|
+
* If you ever see Layer 1 stuck at 0 for a pattern that obviously has signals, check
|
|
302
|
+
* `pattern.evidence.featureId` and `pattern.featureRef` first.
|
|
303
|
+
* @returns {boolean}
|
|
304
|
+
*/
|
|
305
|
+
function overrideMatchesPattern(record, candidateId, featureRef, candidateFeatureId, candidateContextHash) {
|
|
306
|
+
if (!record || record.signalType !== 'override') return false;
|
|
307
|
+
// Primary path (D1): candidateId exists → the pattern was promoted by F-071. F-071 builds
|
|
308
|
+
// candidateId via hashContext("override|<featureId>|<contextKey>") — i.e. featureId is BAKED
|
|
309
|
+
// into the candidate identity. A faithful "candidateId match" therefore requires:
|
|
310
|
+
// (a) record.featureId === pattern's candidate-featureId, AND
|
|
311
|
+
// (b) when the pattern carries a contextHash anchor, record.contextHash matches it.
|
|
312
|
+
// Without (b), all overrides on the same feature match — which is the right behaviour when
|
|
313
|
+
// the pattern doesn't (yet) anchor to a specific contextHash. With (b), the match is strict.
|
|
314
|
+
if (candidateId) {
|
|
315
|
+
if (!candidateFeatureId) return false;
|
|
316
|
+
if (record.featureId !== candidateFeatureId) return false;
|
|
317
|
+
if (candidateContextHash) {
|
|
318
|
+
// Strict identity: same feature AND same contextHash. Without the contextHash match,
|
|
319
|
+
// an override on a different file under the same feature is NOT on the candidate territory.
|
|
320
|
+
return typeof record.contextHash === 'string' && record.contextHash === candidateContextHash;
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
// Defensive fallback (D1): no candidateId → match on featureRef alone.
|
|
325
|
+
if (featureRef && record.featureId === featureRef) return true;
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Does a MEMORY-REF record match this pattern? D3 — memoryFileHash matches one of the
|
|
331
|
+
* pattern's memory-file anchors, OR (defensive fallback) featureRef matches.
|
|
332
|
+
*
|
|
333
|
+
* @param {object} record
|
|
334
|
+
* @param {string[]} memoryHashes
|
|
335
|
+
* @param {string|null} featureRef
|
|
336
|
+
* @returns {boolean}
|
|
337
|
+
*/
|
|
338
|
+
function memoryRefMatchesPattern(record, memoryHashes, featureRef) {
|
|
339
|
+
if (!record || record.signalType !== 'memory-ref') return false;
|
|
340
|
+
if (memoryHashes.length > 0 && typeof record.memoryFileHash === 'string') {
|
|
341
|
+
for (const h of memoryHashes) {
|
|
342
|
+
if (record.memoryFileHash === h) return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Fallback: feature-scoped memory reference.
|
|
346
|
+
if (featureRef && record.featureId === featureRef) return true;
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Does a REGRET record match this pattern? D3 — decisionId matches one of the
|
|
352
|
+
* pattern's anchors, OR (defensive fallback) featureRef matches.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} record
|
|
355
|
+
* @param {string[]} decisionIds
|
|
356
|
+
* @param {string|null} featureRef
|
|
357
|
+
* @returns {boolean}
|
|
358
|
+
*/
|
|
359
|
+
function regretMatchesPattern(record, decisionIds, featureRef) {
|
|
360
|
+
if (!record || record.signalType !== 'regret') return false;
|
|
361
|
+
if (decisionIds.length > 0 && typeof record.decisionId === 'string') {
|
|
362
|
+
for (const d of decisionIds) {
|
|
363
|
+
if (record.decisionId === d) return true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (featureRef && record.featureId === featureRef) return true;
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// -----------------------------------------------------------------------------
|
|
371
|
+
// Internal helpers — deterministic session bookkeeping
|
|
372
|
+
// -----------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Determine the most-recent sessionId across an override corpus. D4: lexicographic
|
|
376
|
+
* max of the ts string with id as tiebreaker. Returns null when the corpus is empty
|
|
377
|
+
* or all records are session-less.
|
|
378
|
+
*
|
|
379
|
+
* AC-7: this is the only place "latest" is computed. No Date.now, no wall clock —
|
|
380
|
+
* we sort by the record's persisted timestamp, which is fully deterministic w.r.t.
|
|
381
|
+
* the input corpus.
|
|
382
|
+
*
|
|
383
|
+
* @param {Array<object>} overrideRecords
|
|
384
|
+
* @returns {string|null}
|
|
385
|
+
*/
|
|
386
|
+
function latestSessionId(overrideRecords) {
|
|
387
|
+
if (!Array.isArray(overrideRecords) || overrideRecords.length === 0) return null;
|
|
388
|
+
let bestTs = null;
|
|
389
|
+
let bestId = null;
|
|
390
|
+
let bestSession = null;
|
|
391
|
+
for (const r of overrideRecords) {
|
|
392
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
393
|
+
const ts = typeof r.ts === 'string' ? r.ts : '';
|
|
394
|
+
const id = typeof r.id === 'string' ? r.id : '';
|
|
395
|
+
// Deterministic ordering: ts desc, then id desc as tiebreaker.
|
|
396
|
+
if (
|
|
397
|
+
bestTs == null
|
|
398
|
+
|| ts > bestTs
|
|
399
|
+
|| (ts === bestTs && id > bestId)
|
|
400
|
+
) {
|
|
401
|
+
bestTs = ts;
|
|
402
|
+
bestId = id;
|
|
403
|
+
bestSession = r.sessionId;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return bestSession;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Compute the set of sessionIds in which the pattern was active across all three
|
|
411
|
+
* signal types. D3 + D6: returned as a SORTED array so any downstream iteration is
|
|
412
|
+
* deterministic regardless of Set/Map insertion order.
|
|
413
|
+
*
|
|
414
|
+
* @param {object} pattern
|
|
415
|
+
* @param {Array<object>} overrides
|
|
416
|
+
* @param {Array<object>} memoryRefs
|
|
417
|
+
* @param {Array<object>} regrets
|
|
418
|
+
* @returns {string[]} Sorted list of sessionIds.
|
|
419
|
+
*/
|
|
420
|
+
function computeActiveSessions(pattern, overrides, memoryRefs, regrets) {
|
|
421
|
+
const candidateId = patternCandidateId(pattern);
|
|
422
|
+
const featureRef = patternFeatureRef(pattern);
|
|
423
|
+
const memoryHashes = patternMemoryFileHashes(pattern);
|
|
424
|
+
const decisionIds = patternDecisionIds(pattern);
|
|
425
|
+
// Pattern-side featureId for override matching (see overrideMatchesPattern doc).
|
|
426
|
+
const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
|
|
427
|
+
? pattern.evidence.featureId
|
|
428
|
+
: featureRef;
|
|
429
|
+
const candidateContextHash = pickPrimaryContextHash(pattern);
|
|
430
|
+
|
|
431
|
+
// Use a plain object as a string-keyed set to avoid Set iteration-order surprises.
|
|
432
|
+
// We sort the final array before returning (D6).
|
|
433
|
+
/** @type {Object<string, true>} */
|
|
434
|
+
const sessions = Object.create(null);
|
|
435
|
+
|
|
436
|
+
for (const r of overrides || []) {
|
|
437
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
438
|
+
if (overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash)) {
|
|
439
|
+
sessions[r.sessionId] = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const r of memoryRefs || []) {
|
|
443
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
444
|
+
if (memoryRefMatchesPattern(r, memoryHashes, featureRef)) {
|
|
445
|
+
sessions[r.sessionId] = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const r of regrets || []) {
|
|
449
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
450
|
+
if (regretMatchesPattern(r, decisionIds, featureRef)) {
|
|
451
|
+
sessions[r.sessionId] = true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// @cap-risk(F-072/AC-7) Sort lock: Object.keys()'s order isn't guaranteed across V8
|
|
456
|
+
// versions for non-numeric strings; an explicit .sort() seals it.
|
|
457
|
+
return Object.keys(sessions).sort();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Pull the primary contextHash anchor off a pattern's evidence, if any. F-071 persists
|
|
462
|
+
* topContextHashes[]; we treat the FIRST entry as the canonical anchor for matching
|
|
463
|
+
* overrides (D1 strictest path). When evidence carries an explicit `contextHash` field
|
|
464
|
+
* (a future LLM-stage pattern shape), that wins.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} pattern
|
|
467
|
+
* @returns {string|null}
|
|
468
|
+
*/
|
|
469
|
+
function pickPrimaryContextHash(pattern) {
|
|
470
|
+
if (!pattern || typeof pattern !== 'object') return null;
|
|
471
|
+
const ev = pattern.evidence;
|
|
472
|
+
if (!ev || typeof ev !== 'object') return null;
|
|
473
|
+
if (typeof ev.contextHash === 'string' && ev.contextHash.length > 0) return ev.contextHash;
|
|
474
|
+
if (Array.isArray(ev.topContextHashes) && ev.topContextHashes.length > 0) {
|
|
475
|
+
const first = ev.topContextHashes[0];
|
|
476
|
+
if (first && typeof first.hash === 'string' && first.hash.length > 0) return first.hash;
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// -----------------------------------------------------------------------------
|
|
482
|
+
// Layer 1 + Layer 2 compute
|
|
483
|
+
// -----------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Layer 1: short-term Override-COUNT (D1) over the most-recent session.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} pattern
|
|
489
|
+
* @param {Array<object>} overrides - Already filtered to signalType=override.
|
|
490
|
+
* @returns {FitnessLayer1}
|
|
491
|
+
*/
|
|
492
|
+
function computeLayer1(pattern, overrides) {
|
|
493
|
+
const lastSession = latestSessionId(overrides);
|
|
494
|
+
const candidateId = patternCandidateId(pattern);
|
|
495
|
+
const featureRef = patternFeatureRef(pattern);
|
|
496
|
+
const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
|
|
497
|
+
? pattern.evidence.featureId
|
|
498
|
+
: featureRef;
|
|
499
|
+
const candidateContextHash = pickPrimaryContextHash(pattern);
|
|
500
|
+
|
|
501
|
+
let count = 0;
|
|
502
|
+
if (lastSession) {
|
|
503
|
+
for (const r of overrides || []) {
|
|
504
|
+
if (!r || r.sessionId !== lastSession) continue;
|
|
505
|
+
if (overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash)) {
|
|
506
|
+
count += 1;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
kind: 'override-count',
|
|
513
|
+
value: count,
|
|
514
|
+
lastSessionId: lastSession,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Layer 2: long-term per-session weighted average (D2). value = (memoryRefs * 1 + regrets * 2) / n,
|
|
520
|
+
* where n = activeSessions.length. ready = (n >= LAYER2_READY_THRESHOLD).
|
|
521
|
+
*
|
|
522
|
+
* AC-5: value is computed even when n < threshold so the data exists from day 1; the consumer
|
|
523
|
+
* (display layer / F-074) gates on ready.
|
|
524
|
+
*
|
|
525
|
+
* @param {object} pattern
|
|
526
|
+
* @param {Array<object>} memoryRefs
|
|
527
|
+
* @param {Array<object>} regrets
|
|
528
|
+
* @param {string[]} activeSessions - Sorted list from computeActiveSessions.
|
|
529
|
+
* @returns {FitnessLayer2}
|
|
530
|
+
*/
|
|
531
|
+
function computeLayer2(pattern, memoryRefs, regrets, activeSessions) {
|
|
532
|
+
const featureRef = patternFeatureRef(pattern);
|
|
533
|
+
const memoryHashes = patternMemoryFileHashes(pattern);
|
|
534
|
+
const decisionIds = patternDecisionIds(pattern);
|
|
535
|
+
|
|
536
|
+
let memoryHits = 0;
|
|
537
|
+
for (const r of memoryRefs || []) {
|
|
538
|
+
if (memoryRefMatchesPattern(r, memoryHashes, featureRef)) memoryHits += 1;
|
|
539
|
+
}
|
|
540
|
+
let regretHits = 0;
|
|
541
|
+
for (const r of regrets || []) {
|
|
542
|
+
if (regretMatchesPattern(r, decisionIds, featureRef)) regretHits += 1;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const n = activeSessions.length;
|
|
546
|
+
// @cap-risk(F-072/AC-7) Divide-by-zero guard: when n=0, value=0. The adversarial test pins this.
|
|
547
|
+
const value = n > 0
|
|
548
|
+
? (memoryHits * WEIGHT_MEMORY_REF + regretHits * WEIGHT_REGRET) / n
|
|
549
|
+
: 0;
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
kind: 'weighted-average',
|
|
553
|
+
value,
|
|
554
|
+
n,
|
|
555
|
+
ready: n >= LAYER2_READY_THRESHOLD,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Find the most-recent matching signal across all types — used to populate
|
|
561
|
+
* lastSeenSessionId / lastSeenAt on the FitnessRecord. D6: deterministic sort by ts.
|
|
562
|
+
*
|
|
563
|
+
* @param {object} pattern
|
|
564
|
+
* @param {Array<object>} overrides
|
|
565
|
+
* @param {Array<object>} memoryRefs
|
|
566
|
+
* @param {Array<object>} regrets
|
|
567
|
+
* @returns {{sessionId: string|null, ts: string|null}}
|
|
568
|
+
*/
|
|
569
|
+
function lastSeen(pattern, overrides, memoryRefs, regrets) {
|
|
570
|
+
const candidateId = patternCandidateId(pattern);
|
|
571
|
+
const featureRef = patternFeatureRef(pattern);
|
|
572
|
+
const memoryHashes = patternMemoryFileHashes(pattern);
|
|
573
|
+
const decisionIds = patternDecisionIds(pattern);
|
|
574
|
+
const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
|
|
575
|
+
? pattern.evidence.featureId
|
|
576
|
+
: featureRef;
|
|
577
|
+
const candidateContextHash = pickPrimaryContextHash(pattern);
|
|
578
|
+
|
|
579
|
+
let bestTs = null;
|
|
580
|
+
let bestId = null;
|
|
581
|
+
let bestSession = null;
|
|
582
|
+
|
|
583
|
+
const consider = (r, isMatch) => {
|
|
584
|
+
if (!r || !isMatch) return;
|
|
585
|
+
const ts = typeof r.ts === 'string' ? r.ts : '';
|
|
586
|
+
const id = typeof r.id === 'string' ? r.id : '';
|
|
587
|
+
if (
|
|
588
|
+
bestTs == null
|
|
589
|
+
|| ts > bestTs
|
|
590
|
+
|| (ts === bestTs && id > bestId)
|
|
591
|
+
) {
|
|
592
|
+
bestTs = ts;
|
|
593
|
+
bestId = id;
|
|
594
|
+
bestSession = (typeof r.sessionId === 'string' && r.sessionId.length > 0) ? r.sessionId : null;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
for (const r of overrides || []) {
|
|
599
|
+
consider(r, overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash));
|
|
600
|
+
}
|
|
601
|
+
for (const r of memoryRefs || []) {
|
|
602
|
+
consider(r, memoryRefMatchesPattern(r, memoryHashes, featureRef));
|
|
603
|
+
}
|
|
604
|
+
for (const r of regrets || []) {
|
|
605
|
+
consider(r, regretMatchesPattern(r, decisionIds, featureRef));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { sessionId: bestSession, ts: bestTs };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Compute every union session id across the three corpora (signal-source sessions —
|
|
613
|
+
* NOT pattern-active sessions). Used by the AC-4 expiry check: a pattern with no
|
|
614
|
+
* activity in the last EXPIRY_SESSIONS *signal-corpus* sessions is expired.
|
|
615
|
+
*
|
|
616
|
+
* Sessions are ordered by their max-ts (most-recent ts seen with that sessionId);
|
|
617
|
+
* D6 demands a deterministic order, so ties on max-ts fall back to lexicographic
|
|
618
|
+
* sessionId order. The returned array is most-recent-first.
|
|
619
|
+
*
|
|
620
|
+
* @param {Array<object>} overrides
|
|
621
|
+
* @param {Array<object>} memoryRefs
|
|
622
|
+
* @param {Array<object>} regrets
|
|
623
|
+
* @returns {string[]} Sessions in most-recent-first order.
|
|
624
|
+
*/
|
|
625
|
+
function unionSessionsByRecency(overrides, memoryRefs, regrets) {
|
|
626
|
+
/** @type {Object<string, string>} */
|
|
627
|
+
const sessionMaxTs = Object.create(null);
|
|
628
|
+
const collect = (arr) => {
|
|
629
|
+
for (const r of arr || []) {
|
|
630
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
631
|
+
const ts = typeof r.ts === 'string' ? r.ts : '';
|
|
632
|
+
const prev = sessionMaxTs[r.sessionId];
|
|
633
|
+
if (prev == null || ts > prev) sessionMaxTs[r.sessionId] = ts;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
collect(overrides);
|
|
637
|
+
collect(memoryRefs);
|
|
638
|
+
collect(regrets);
|
|
639
|
+
|
|
640
|
+
// @cap-risk(F-072/AC-7) Sort by (max-ts desc, sessionId desc) for full determinism.
|
|
641
|
+
const sessions = Object.keys(sessionMaxTs);
|
|
642
|
+
sessions.sort((a, b) => {
|
|
643
|
+
const ta = sessionMaxTs[a];
|
|
644
|
+
const tb = sessionMaxTs[b];
|
|
645
|
+
if (ta < tb) return 1;
|
|
646
|
+
if (ta > tb) return -1;
|
|
647
|
+
if (a < b) return 1;
|
|
648
|
+
if (a > b) return -1;
|
|
649
|
+
return 0;
|
|
650
|
+
});
|
|
651
|
+
return sessions;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// -----------------------------------------------------------------------------
|
|
655
|
+
// Public API — computeFitness
|
|
656
|
+
// -----------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
// @cap-todo(ac:F-072/AC-1) Layer 1 short-term override-count over the last session.
|
|
659
|
+
// @cap-todo(ac:F-072/AC-2) Layer 2 long-term weighted per-session average; ready at n>=5.
|
|
660
|
+
// @cap-todo(ac:F-072/AC-5) Layer 2 value is computed and persisted from day 1, ready=false below threshold.
|
|
661
|
+
// @cap-todo(ac:F-072/AC-7) Pure compute, deterministic — no random / no Date.now in the formulas.
|
|
662
|
+
/**
|
|
663
|
+
* Compute the FitnessRecord for `patternId` from the current signal corpus. Pure compute,
|
|
664
|
+
* no IO except reading via cap-learning-signals.getSignals + cap-pattern-pipeline.listPatterns.
|
|
665
|
+
*
|
|
666
|
+
* @param {string} projectRoot
|
|
667
|
+
* @param {string} patternId - 'P-NNN'
|
|
668
|
+
* @param {Object} [options]
|
|
669
|
+
* @param {Date|string} [options.now] - Override the persisted ts (mostly for tests). NEVER affects the formulas.
|
|
670
|
+
* @returns {FitnessRecord|null} null when projectRoot/patternId invalid or pattern not found.
|
|
671
|
+
*/
|
|
672
|
+
function computeFitness(projectRoot, patternId, options) {
|
|
673
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
|
|
674
|
+
if (!isValidPatternId(patternId)) return null;
|
|
675
|
+
const opts = options || {};
|
|
676
|
+
|
|
677
|
+
const pattern = findPattern(projectRoot, patternId);
|
|
678
|
+
if (!pattern) return null;
|
|
679
|
+
|
|
680
|
+
// Pull all three corpora via the F-070 query API. We never read JSONL directly.
|
|
681
|
+
let overrides = [];
|
|
682
|
+
let memoryRefs = [];
|
|
683
|
+
let regrets = [];
|
|
684
|
+
try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
|
|
685
|
+
try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
|
|
686
|
+
try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
|
|
687
|
+
|
|
688
|
+
return computeFitnessFromCorpus(pattern, overrides, memoryRefs, regrets, opts.now);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Internal worker — computes a FitnessRecord from a pre-loaded signal corpus. Used by
|
|
693
|
+
* computeFitness (one-shot) and runFitnessPass (batch optimisation: read corpus once).
|
|
694
|
+
*
|
|
695
|
+
* @param {object} pattern
|
|
696
|
+
* @param {Array<object>} overrides
|
|
697
|
+
* @param {Array<object>} memoryRefs
|
|
698
|
+
* @param {Array<object>} regrets
|
|
699
|
+
* @param {Date|string} [now]
|
|
700
|
+
* @returns {FitnessRecord}
|
|
701
|
+
*/
|
|
702
|
+
function computeFitnessFromCorpus(pattern, overrides, memoryRefs, regrets, now) {
|
|
703
|
+
const activeSessions = computeActiveSessions(pattern, overrides, memoryRefs, regrets);
|
|
704
|
+
const layer1 = computeLayer1(pattern, overrides);
|
|
705
|
+
const layer2 = computeLayer2(pattern, memoryRefs, regrets, activeSessions);
|
|
706
|
+
const seen = lastSeen(pattern, overrides, memoryRefs, regrets);
|
|
707
|
+
|
|
708
|
+
const ts = now ? new Date(now).toISOString() : new Date().toISOString();
|
|
709
|
+
|
|
710
|
+
/** @type {FitnessRecord} */
|
|
711
|
+
return {
|
|
712
|
+
id: pattern.id,
|
|
713
|
+
patternId: pattern.id,
|
|
714
|
+
ts,
|
|
715
|
+
layer1,
|
|
716
|
+
layer2,
|
|
717
|
+
activeSessions: activeSessions.length,
|
|
718
|
+
lastSeenSessionId: seen.sessionId,
|
|
719
|
+
lastSeenAt: seen.ts,
|
|
720
|
+
expired: false,
|
|
721
|
+
evidence: {
|
|
722
|
+
candidateId: patternCandidateId(pattern),
|
|
723
|
+
featureRef: patternFeatureRef(pattern),
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// -----------------------------------------------------------------------------
|
|
729
|
+
// Public API — recordFitness / getFitness
|
|
730
|
+
// -----------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
// @cap-todo(ac:F-072/AC-3) Persistence layer — getFitness round-trips computeFitness.
|
|
733
|
+
// Rolling-30 / Lifetime aggregates: the persisted record IS the
|
|
734
|
+
// lifetime aggregate (every signal across all sessions); rolling-30
|
|
735
|
+
// is reconstructible per-call by restricting the corpus, but the
|
|
736
|
+
// MVP persists the lifetime view and surfaces it as the canonical
|
|
737
|
+
// fitness record. Snapshot history (recordApplySnapshot) handles
|
|
738
|
+
// the rolling-history view F-074 needs.
|
|
739
|
+
/**
|
|
740
|
+
* Compute + persist a FitnessRecord to .cap/learning/fitness/<P-NNN>.json. Idempotent within
|
|
741
|
+
* a session — re-computes from scratch and overwrites the prior write.
|
|
742
|
+
*
|
|
743
|
+
* @param {string} projectRoot
|
|
744
|
+
* @param {string} patternId - 'P-NNN'
|
|
745
|
+
* @param {Object} [options]
|
|
746
|
+
* @param {Date|string} [options.now]
|
|
747
|
+
* @returns {boolean} true on successful write; false on invalid input or IO error.
|
|
748
|
+
*/
|
|
749
|
+
function recordFitness(projectRoot, patternId, options) {
|
|
750
|
+
const record = computeFitness(projectRoot, patternId, options);
|
|
751
|
+
if (!record) return false;
|
|
752
|
+
ensureDir(fitnessDir(projectRoot));
|
|
753
|
+
try {
|
|
754
|
+
fs.writeFileSync(fitnessFilePath(projectRoot, patternId), JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
755
|
+
return true;
|
|
756
|
+
} catch (_e) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Read the persisted FitnessRecord for `patternId`. Returns null when the file
|
|
763
|
+
* is missing or malformed. Never throws.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} projectRoot
|
|
766
|
+
* @param {string} patternId
|
|
767
|
+
* @returns {FitnessRecord|null}
|
|
768
|
+
*/
|
|
769
|
+
function getFitness(projectRoot, patternId) {
|
|
770
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
|
|
771
|
+
if (!isValidPatternId(patternId)) return null;
|
|
772
|
+
const fp = fitnessFilePath(projectRoot, patternId);
|
|
773
|
+
try {
|
|
774
|
+
if (!fs.existsSync(fp)) return null;
|
|
775
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
776
|
+
const parsed = JSON.parse(raw);
|
|
777
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
778
|
+
return parsed;
|
|
779
|
+
} catch (_e) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// -----------------------------------------------------------------------------
|
|
785
|
+
// Public API — recordApplySnapshot (AC-6, F-073 hook point)
|
|
786
|
+
// -----------------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
// @cap-todo(ac:F-072/AC-6) Apply-time snapshot — F-073 calls recordApplySnapshot when the user
|
|
789
|
+
// applies a pattern; F-074 reads .snapshots.jsonl tails to compare
|
|
790
|
+
// pre-apply vs post-apply fitness.
|
|
791
|
+
/**
|
|
792
|
+
* Compute current fitness AND append a SnapshotRecord to .cap/learning/fitness/<P-NNN>.snapshots.jsonl.
|
|
793
|
+
* The append-only log (D5) means multiple applies in the same session produce multiple lines.
|
|
794
|
+
*
|
|
795
|
+
* F-073 calls this when the user applies a pattern (we expose the API; F-073 wires the call).
|
|
796
|
+
*
|
|
797
|
+
* @param {string} projectRoot
|
|
798
|
+
* @param {string} patternId
|
|
799
|
+
* @param {Object} [options]
|
|
800
|
+
* @param {Date|string} [options.now]
|
|
801
|
+
* @returns {SnapshotRecord|null} null when projectRoot/patternId invalid, pattern missing, or write failed.
|
|
802
|
+
*/
|
|
803
|
+
function recordApplySnapshot(projectRoot, patternId, options) {
|
|
804
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
|
|
805
|
+
if (!isValidPatternId(patternId)) return null;
|
|
806
|
+
const opts = options || {};
|
|
807
|
+
|
|
808
|
+
const pattern = findPattern(projectRoot, patternId);
|
|
809
|
+
if (!pattern) return null;
|
|
810
|
+
|
|
811
|
+
let overrides = [];
|
|
812
|
+
let memoryRefs = [];
|
|
813
|
+
let regrets = [];
|
|
814
|
+
try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
|
|
815
|
+
try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
|
|
816
|
+
try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
|
|
817
|
+
|
|
818
|
+
const activeSessions = computeActiveSessions(pattern, overrides, memoryRefs, regrets);
|
|
819
|
+
const layer1 = computeLayer1(pattern, overrides);
|
|
820
|
+
const layer2 = computeLayer2(pattern, memoryRefs, regrets, activeSessions);
|
|
821
|
+
const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
822
|
+
|
|
823
|
+
/** @type {SnapshotRecord} */
|
|
824
|
+
const snapshot = {
|
|
825
|
+
ts,
|
|
826
|
+
patternId,
|
|
827
|
+
layer1,
|
|
828
|
+
layer2,
|
|
829
|
+
n: layer2.n,
|
|
830
|
+
activeSessionsList: activeSessions, // already sorted by computeActiveSessions
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
ensureDir(fitnessDir(projectRoot));
|
|
834
|
+
try {
|
|
835
|
+
const line = JSON.stringify(snapshot) + '\n';
|
|
836
|
+
const fd = fs.openSync(snapshotsFilePath(projectRoot, patternId), 'a');
|
|
837
|
+
try {
|
|
838
|
+
fs.writeSync(fd, line);
|
|
839
|
+
} finally {
|
|
840
|
+
fs.closeSync(fd);
|
|
841
|
+
}
|
|
842
|
+
return snapshot;
|
|
843
|
+
} catch (_e) {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// -----------------------------------------------------------------------------
|
|
849
|
+
// Public API — listFitnessExpired / markExpired (AC-4)
|
|
850
|
+
// -----------------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
// @cap-todo(ac:F-072/AC-4) Patterns with no usage over EXPIRY_SESSIONS sessions auto-marked expired.
|
|
853
|
+
/**
|
|
854
|
+
* Return the list of pattern ids that have had no activity in the last EXPIRY_SESSIONS
|
|
855
|
+
* signal-corpus sessions. The window is computed from the union of all three signal
|
|
856
|
+
* types' sessionIds, ordered by most-recent ts (D6 deterministic sort); a pattern is
|
|
857
|
+
* "expired" iff intersect(activeSessions, last20) === emptySet.
|
|
858
|
+
*
|
|
859
|
+
* Edge case: when the corpus has fewer than EXPIRY_SESSIONS distinct sessions, no
|
|
860
|
+
* pattern is considered expired (we don't have enough data yet).
|
|
861
|
+
*
|
|
862
|
+
* @param {string} projectRoot
|
|
863
|
+
* @param {Object} [options]
|
|
864
|
+
* @param {number} [options.window] - Override EXPIRY_SESSIONS (mostly for tests).
|
|
865
|
+
* @returns {string[]} Pattern ids — sorted ascending for deterministic output.
|
|
866
|
+
*/
|
|
867
|
+
/**
|
|
868
|
+
* Pure-compute helper: given an in-memory corpus and pattern list, return ids of patterns
|
|
869
|
+
* that have not been active in any of the most-recent `window` sessions. Both
|
|
870
|
+
* listFitnessExpired (which loads the corpus from disk) and runFitnessPass (which already
|
|
871
|
+
* has it in hand) call this — single source of truth for the expiry rule.
|
|
872
|
+
*
|
|
873
|
+
* @cap-risk(F-072/AC-7) Sorted output for deterministic behaviour. The sort lock is here,
|
|
874
|
+
* not at every call site, so the next contributor cannot accidentally
|
|
875
|
+
* remove it from one path while leaving it in the other.
|
|
876
|
+
*
|
|
877
|
+
* @param {Array<object>} patterns
|
|
878
|
+
* @param {Array<object>} overrides
|
|
879
|
+
* @param {Array<object>} memoryRefs
|
|
880
|
+
* @param {Array<object>} regrets
|
|
881
|
+
* @param {number} window - Number of most-recent sessions defining the activity window.
|
|
882
|
+
* @returns {string[]} Pattern ids — sorted ascending.
|
|
883
|
+
*/
|
|
884
|
+
function expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window) {
|
|
885
|
+
const recencyOrdered = unionSessionsByRecency(overrides, memoryRefs, regrets);
|
|
886
|
+
if (recencyOrdered.length < window) return [];
|
|
887
|
+
const last = new Set(recencyOrdered.slice(0, window));
|
|
888
|
+
|
|
889
|
+
const expired = [];
|
|
890
|
+
for (const p of patterns) {
|
|
891
|
+
if (!p || !isValidPatternId(p.id)) continue;
|
|
892
|
+
const active = computeActiveSessions(p, overrides, memoryRefs, regrets);
|
|
893
|
+
let intersects = false;
|
|
894
|
+
for (const sid of active) {
|
|
895
|
+
if (last.has(sid)) { intersects = true; break; }
|
|
896
|
+
}
|
|
897
|
+
if (!intersects) expired.push(p.id);
|
|
898
|
+
}
|
|
899
|
+
expired.sort();
|
|
900
|
+
return expired;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function listFitnessExpired(projectRoot, options) {
|
|
904
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return [];
|
|
905
|
+
const opts = options || {};
|
|
906
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : EXPIRY_SESSIONS;
|
|
907
|
+
|
|
908
|
+
let overrides = [];
|
|
909
|
+
let memoryRefs = [];
|
|
910
|
+
let regrets = [];
|
|
911
|
+
try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
|
|
912
|
+
try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
|
|
913
|
+
try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
|
|
914
|
+
|
|
915
|
+
const patterns = patternPipeline.listPatterns(projectRoot) || [];
|
|
916
|
+
return expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Mark a persisted FitnessRecord as expired. Reads the existing record (if any) and
|
|
921
|
+
* sets expired=true. When the record doesn't yet exist, computes a fresh one and
|
|
922
|
+
* persists it with expired=true so getFitness reflects the change. Returns true on
|
|
923
|
+
* successful write.
|
|
924
|
+
*
|
|
925
|
+
* @param {string} projectRoot
|
|
926
|
+
* @param {string} patternId
|
|
927
|
+
* @returns {boolean}
|
|
928
|
+
*/
|
|
929
|
+
function markExpired(projectRoot, patternId) {
|
|
930
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
931
|
+
if (!isValidPatternId(patternId)) return false;
|
|
932
|
+
|
|
933
|
+
let record = getFitness(projectRoot, patternId);
|
|
934
|
+
if (!record) {
|
|
935
|
+
record = computeFitness(projectRoot, patternId);
|
|
936
|
+
if (!record) return false;
|
|
937
|
+
}
|
|
938
|
+
record.expired = true;
|
|
939
|
+
ensureDir(fitnessDir(projectRoot));
|
|
940
|
+
try {
|
|
941
|
+
fs.writeFileSync(fitnessFilePath(projectRoot, patternId), JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
942
|
+
return true;
|
|
943
|
+
} catch (_e) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// -----------------------------------------------------------------------------
|
|
949
|
+
// Public API — runFitnessPass (batch helper for /cap:learn Step 6.5)
|
|
950
|
+
// -----------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
// @cap-decision(F-072/D7) /cap:learn Step 6.5 calls runFitnessPass(projectRoot) as a courtesy refresh
|
|
953
|
+
// — every learn invocation re-computes fitness for all patterns. Cost is bounded by
|
|
954
|
+
// the performance probe (<500ms for 100 patterns × 1000 signals); the additive step
|
|
955
|
+
// doesn't refactor the existing 7 steps in commands/cap/learn.md.
|
|
956
|
+
/**
|
|
957
|
+
* Refresh fitness for every persisted pattern AND auto-mark expired ones. Used by
|
|
958
|
+
* /cap:learn Step 6.5 (additive) and any future /cap:fitness skill.
|
|
959
|
+
*
|
|
960
|
+
* @param {string} projectRoot
|
|
961
|
+
* @param {Object} [options]
|
|
962
|
+
* @param {Date|string} [options.now]
|
|
963
|
+
* @param {number} [options.window] - Override EXPIRY_SESSIONS.
|
|
964
|
+
* @returns {{recorded: string[], expired: string[], errors: string[]}}
|
|
965
|
+
*/
|
|
966
|
+
function runFitnessPass(projectRoot, options) {
|
|
967
|
+
const opts = options || {};
|
|
968
|
+
const recorded = [];
|
|
969
|
+
const expired = [];
|
|
970
|
+
const errors = [];
|
|
971
|
+
|
|
972
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
973
|
+
return { recorded, expired, errors: ['projectRoot is required'] };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
let patterns = [];
|
|
977
|
+
try {
|
|
978
|
+
patterns = patternPipeline.listPatterns(projectRoot) || [];
|
|
979
|
+
} catch (e) {
|
|
980
|
+
errors.push(`listPatterns failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
981
|
+
return { recorded, expired, errors };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// @cap-risk(F-072/AC-7) Sort patterns by id ascending so iteration order is stable
|
|
985
|
+
// regardless of fs.readdir's filesystem-dependent ordering.
|
|
986
|
+
patterns = [...patterns].sort((a, b) => {
|
|
987
|
+
const ai = (a && a.id) || '';
|
|
988
|
+
const bi = (b && b.id) || '';
|
|
989
|
+
if (ai < bi) return -1;
|
|
990
|
+
if (ai > bi) return 1;
|
|
991
|
+
return 0;
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// @cap-risk(F-072/AC-7) Performance: read the three signal corpora ONCE, then run the
|
|
995
|
+
// per-pattern compute against the in-memory arrays. Without this batch
|
|
996
|
+
// optimisation, each recordFitness call re-reads the corpora — O(P²)
|
|
997
|
+
// in pattern count vs the O(P) we want for runFitnessPass. The numerical
|
|
998
|
+
// result is identical (we still call the same compute helpers), but the
|
|
999
|
+
// perf probe (100 patterns × 1000 signals) hits the 500ms budget.
|
|
1000
|
+
let overrides = [];
|
|
1001
|
+
let memoryRefs = [];
|
|
1002
|
+
let regrets = [];
|
|
1003
|
+
try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (e) {
|
|
1004
|
+
errors.push(`getSignals(override) failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
1005
|
+
}
|
|
1006
|
+
try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (e) {
|
|
1007
|
+
errors.push(`getSignals(memory-ref) failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
1008
|
+
}
|
|
1009
|
+
try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (e) {
|
|
1010
|
+
errors.push(`getSignals(regret) failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
ensureDir(fitnessDir(projectRoot));
|
|
1014
|
+
|
|
1015
|
+
for (const p of patterns) {
|
|
1016
|
+
if (!p || !isValidPatternId(p.id)) continue;
|
|
1017
|
+
try {
|
|
1018
|
+
const record = computeFitnessFromCorpus(p, overrides, memoryRefs, regrets, opts.now);
|
|
1019
|
+
try {
|
|
1020
|
+
fs.writeFileSync(fitnessFilePath(projectRoot, p.id), JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
1021
|
+
recorded.push(p.id);
|
|
1022
|
+
} catch (we) {
|
|
1023
|
+
errors.push(`recordFitness write failed for ${p.id}: ${we && we.message ? we.message : 'unknown'}`);
|
|
1024
|
+
}
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
errors.push(`recordFitness threw for ${p.id}: ${e && e.message ? e.message : 'unknown'}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Expiry check reuses the in-memory corpus to avoid a second disk read.
|
|
1031
|
+
// expiredIdsFromCorpus is the single source of truth — listFitnessExpired calls it too.
|
|
1032
|
+
let expiredIds = [];
|
|
1033
|
+
try {
|
|
1034
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : EXPIRY_SESSIONS;
|
|
1035
|
+
expiredIds = expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window);
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
errors.push(`expired check failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
1038
|
+
}
|
|
1039
|
+
for (const id of expiredIds) {
|
|
1040
|
+
try {
|
|
1041
|
+
if (markExpired(projectRoot, id)) expired.push(id);
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
errors.push(`markExpired failed for ${id}: ${e && e.message ? e.message : 'unknown'}`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return { recorded, expired, errors };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// -----------------------------------------------------------------------------
|
|
1051
|
+
// Exports — keep this list minimal. F-073 / F-074 should consume only these.
|
|
1052
|
+
// -----------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
module.exports = {
|
|
1055
|
+
// Constants — exported for tests + downstream consumers.
|
|
1056
|
+
CAP_DIR,
|
|
1057
|
+
LEARNING_DIR,
|
|
1058
|
+
FITNESS_DIR,
|
|
1059
|
+
LAYER2_READY_THRESHOLD,
|
|
1060
|
+
WEIGHT_MEMORY_REF,
|
|
1061
|
+
WEIGHT_REGRET,
|
|
1062
|
+
EXPIRY_SESSIONS,
|
|
1063
|
+
// Public API.
|
|
1064
|
+
computeFitness,
|
|
1065
|
+
recordFitness,
|
|
1066
|
+
getFitness,
|
|
1067
|
+
recordApplySnapshot,
|
|
1068
|
+
listFitnessExpired,
|
|
1069
|
+
markExpired,
|
|
1070
|
+
runFitnessPass,
|
|
1071
|
+
// Path helpers — exported for tests.
|
|
1072
|
+
fitnessDir,
|
|
1073
|
+
fitnessFilePath,
|
|
1074
|
+
snapshotsFilePath,
|
|
1075
|
+
};
|