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,707 @@
|
|
|
1
|
+
// @cap-feature(feature:F-056) Memory Prune Command — decay, archive, raw-log purge.
|
|
2
|
+
// @cap-decision Default dry-run — --apply required to mutate files (AC-2 is a data-safety commitment).
|
|
3
|
+
// @cap-decision Archive path uses the archival month (when pruned), not the entry's own month — simplifies filename collisions and gives a rolling history.
|
|
4
|
+
// @cap-decision Pinned entries (metadata.pinned:true) are never decayed nor archived — F-030 pin semantics outweigh decay/TTL.
|
|
5
|
+
// @cap-constraint Zero external deps — node: built-ins only.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// @cap-history(sessions:3, edits:9, since:2026-04-20, learned:2026-05-07) Frequently modified — 3 sessions, 9 edits
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const confidence = require('./cap-memory-confidence.cjs');
|
|
14
|
+
const {
|
|
15
|
+
writeMemoryDirectory,
|
|
16
|
+
readMemoryFile,
|
|
17
|
+
MEMORY_DIR,
|
|
18
|
+
CATEGORY_FILES,
|
|
19
|
+
} = require('./cap-memory-dir.cjs');
|
|
20
|
+
// @cap-feature(feature:F-086) cap-memory-prune consumes the shared scope filter so its
|
|
21
|
+
// --gitignored mode uses the same path-decision logic as the scanner and migrator.
|
|
22
|
+
const scopeModule = require('./cap-scope-filter.cjs');
|
|
23
|
+
|
|
24
|
+
// --- Constants ---
|
|
25
|
+
|
|
26
|
+
const DECAY_START_DAYS = 90;
|
|
27
|
+
const DECAY_STEP_DAYS = 30;
|
|
28
|
+
const DECAY_AMOUNT = 0.05;
|
|
29
|
+
const ARCHIVE_CONFIDENCE_THRESHOLD = 0.2;
|
|
30
|
+
const ARCHIVE_AGE_DAYS = 180;
|
|
31
|
+
const RAW_LOG_RETENTION_DAYS = 30;
|
|
32
|
+
const CONFIDENCE_FLOOR = 0.0;
|
|
33
|
+
|
|
34
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
const RAW_LOG_DIR_PARTS = ['.cap', 'memory', 'raw'];
|
|
37
|
+
const ARCHIVE_DIR_PARTS = ['.cap', 'memory', 'archive'];
|
|
38
|
+
const PRUNE_LOG_PARTS = ['.cap', 'memory', 'prune-log.jsonl'];
|
|
39
|
+
|
|
40
|
+
// Decay-eligible categories: hotspots excluded (ranking-table format, regenerated fresh each run).
|
|
41
|
+
const DECAY_CATEGORIES = ['decision', 'pitfall', 'pattern'];
|
|
42
|
+
|
|
43
|
+
// --- Types ---
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {{category:string, content:string, file?:string, metadata:Object}} MemoryEntry
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
// --- Pure helpers ---
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {Date|string|undefined|null} value
|
|
53
|
+
* @returns {number|null} milliseconds since epoch, or null if invalid
|
|
54
|
+
*/
|
|
55
|
+
function toMillis(value) {
|
|
56
|
+
if (value instanceof Date) {
|
|
57
|
+
const ms = value.getTime();
|
|
58
|
+
return Number.isFinite(ms) ? ms : null;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
61
|
+
const ms = Date.parse(value);
|
|
62
|
+
if (!Number.isFinite(ms)) return null;
|
|
63
|
+
// Date.parse silently normalises overflow calendar dates ("2026-02-30" → March 2),
|
|
64
|
+
// which would make "invalid" inputs yield plausible ages. Reject anything whose
|
|
65
|
+
// YYYY-MM-DD prefix doesn't roundtrip through the parsed timestamp.
|
|
66
|
+
const isoPrefix = value.substring(0, 10);
|
|
67
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(isoPrefix) && new Date(ms).toISOString().substring(0, 10) !== isoPrefix) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return ms;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whole days between two Date-or-ISO-string inputs. UTC-aligned, floored.
|
|
77
|
+
* Invalid inputs yield Infinity (semantically "very old").
|
|
78
|
+
* @param {Date|string} a
|
|
79
|
+
* @param {Date|string} b
|
|
80
|
+
* @returns {number}
|
|
81
|
+
*/
|
|
82
|
+
function daysBetween(a, b) {
|
|
83
|
+
const ma = toMillis(a);
|
|
84
|
+
const mb = toMillis(b);
|
|
85
|
+
if (ma === null || mb === null) return Infinity;
|
|
86
|
+
return Math.floor(Math.abs(mb - ma) / MS_PER_DAY);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compute decayed confidence for an entry.
|
|
91
|
+
* 0 steps when age <= DECAY_START_DAYS.
|
|
92
|
+
* Otherwise floor((age - start) / step) decay events of DECAY_AMOUNT each.
|
|
93
|
+
* Floored at CONFIDENCE_FLOOR.
|
|
94
|
+
* @cap-todo(ac:F-056/AC-3)
|
|
95
|
+
* @param {number} currentConfidence
|
|
96
|
+
* @param {Date|string} lastSeen
|
|
97
|
+
* @param {Date} now
|
|
98
|
+
* @returns {{newConfidence:number, steps:number}}
|
|
99
|
+
*/
|
|
100
|
+
function computeDecay(currentConfidence, lastSeen, now) {
|
|
101
|
+
const age = daysBetween(lastSeen, now);
|
|
102
|
+
if (!Number.isFinite(age)) {
|
|
103
|
+
// "Very old" — run decay until floor.
|
|
104
|
+
const raw = typeof currentConfidence === 'number' ? currentConfidence : confidence.DEFAULT_CONFIDENCE;
|
|
105
|
+
return { newConfidence: CONFIDENCE_FLOOR, steps: Math.ceil(raw / DECAY_AMOUNT) };
|
|
106
|
+
}
|
|
107
|
+
if (age <= DECAY_START_DAYS) {
|
|
108
|
+
return { newConfidence: round2(currentConfidence), steps: 0 };
|
|
109
|
+
}
|
|
110
|
+
const steps = Math.floor((age - DECAY_START_DAYS) / DECAY_STEP_DAYS);
|
|
111
|
+
if (steps <= 0) {
|
|
112
|
+
return { newConfidence: round2(currentConfidence), steps: 0 };
|
|
113
|
+
}
|
|
114
|
+
const raw = Math.max(CONFIDENCE_FLOOR, currentConfidence - steps * DECAY_AMOUNT);
|
|
115
|
+
return { newConfidence: round2(raw), steps };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @cap-todo(ac:F-056/AC-4)
|
|
120
|
+
* @param {number} conf
|
|
121
|
+
* @param {Date|string} lastSeen
|
|
122
|
+
* @param {Date} now
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function shouldArchive(conf, lastSeen, now) {
|
|
126
|
+
const age = daysBetween(lastSeen, now);
|
|
127
|
+
if (typeof conf !== 'number' || Number.isNaN(conf)) return false;
|
|
128
|
+
return conf < ARCHIVE_CONFIDENCE_THRESHOLD && age > ARCHIVE_AGE_DAYS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Two-decimal rounding to keep markdown clean (avoids 0.30000000000000004).
|
|
133
|
+
* @param {number} n
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
function round2(n) {
|
|
137
|
+
if (typeof n !== 'number' || Number.isNaN(n)) return 0;
|
|
138
|
+
return Math.round(n * 100) / 100;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Split entries into kept / decayed / archived buckets.
|
|
143
|
+
* Decay is applied BEFORE the archive check so an entry that crosses the
|
|
144
|
+
* ARCHIVE_CONFIDENCE_THRESHOLD *due to decay* is archived in the same run.
|
|
145
|
+
* Pinned entries bypass both.
|
|
146
|
+
* @cap-todo(ac:F-056/AC-3)
|
|
147
|
+
* @cap-todo(ac:F-056/AC-4)
|
|
148
|
+
* @param {MemoryEntry[]} entries
|
|
149
|
+
* @param {Date} now
|
|
150
|
+
* @returns {{kept:MemoryEntry[], decayed:Array<{entry:MemoryEntry, oldConf:number, newConf:number, steps:number}>, archived:MemoryEntry[]}}
|
|
151
|
+
*/
|
|
152
|
+
function classifyEntries(entries, now) {
|
|
153
|
+
const kept = [];
|
|
154
|
+
const decayed = [];
|
|
155
|
+
const archived = [];
|
|
156
|
+
|
|
157
|
+
for (const raw of entries || []) {
|
|
158
|
+
if (!raw || !raw.metadata) {
|
|
159
|
+
kept.push(raw);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const meta = confidence.ensureFields(raw.metadata);
|
|
164
|
+
const entry = { ...raw, metadata: meta };
|
|
165
|
+
|
|
166
|
+
if (meta.pinned === true) {
|
|
167
|
+
kept.push(entry);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { newConfidence, steps } = computeDecay(meta.confidence, meta.last_seen, now);
|
|
172
|
+
const didDecay = steps > 0 && newConfidence !== meta.confidence;
|
|
173
|
+
|
|
174
|
+
const postDecayMeta = didDecay
|
|
175
|
+
? { ...meta, confidence: newConfidence }
|
|
176
|
+
: meta;
|
|
177
|
+
const postDecayEntry = didDecay
|
|
178
|
+
? { ...entry, metadata: postDecayMeta }
|
|
179
|
+
: entry;
|
|
180
|
+
|
|
181
|
+
if (shouldArchive(postDecayMeta.confidence, postDecayMeta.last_seen, now)) {
|
|
182
|
+
archived.push(postDecayEntry);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (didDecay) {
|
|
187
|
+
decayed.push({ entry: postDecayEntry, oldConf: meta.confidence, newConf: newConfidence, steps });
|
|
188
|
+
}
|
|
189
|
+
kept.push(postDecayEntry);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { kept, decayed, archived };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Raw-log selection (AC-5) ---
|
|
196
|
+
|
|
197
|
+
const RAW_LOG_FILENAME_RE = /^tag-events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Select raw-event-log files older than maxAgeDays.
|
|
201
|
+
* Ignores files without the tag-events-YYYY-MM-DD prefix, invalid dates, and subdirectories.
|
|
202
|
+
* @cap-todo(ac:F-056/AC-5)
|
|
203
|
+
* @param {string} rawDir - Absolute directory path
|
|
204
|
+
* @param {Date} now
|
|
205
|
+
* @param {number} [maxAgeDays=RAW_LOG_RETENTION_DAYS]
|
|
206
|
+
* @returns {string[]} Absolute paths of stale log files
|
|
207
|
+
*/
|
|
208
|
+
function selectStaleRawLogs(rawDir, now, maxAgeDays = RAW_LOG_RETENTION_DAYS) {
|
|
209
|
+
if (!rawDir || !fs.existsSync(rawDir)) return [];
|
|
210
|
+
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = fs.readdirSync(rawDir, { withFileTypes: true });
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stale = [];
|
|
219
|
+
for (const dirent of entries) {
|
|
220
|
+
if (!dirent.isFile()) continue;
|
|
221
|
+
const m = RAW_LOG_FILENAME_RE.exec(dirent.name);
|
|
222
|
+
if (!m) continue;
|
|
223
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}T00:00:00.000Z`;
|
|
224
|
+
const age = daysBetween(iso, now);
|
|
225
|
+
if (!Number.isFinite(age)) continue;
|
|
226
|
+
if (age > maxAgeDays) {
|
|
227
|
+
stale.push(path.join(rawDir, dirent.name));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return stale;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Reporting ---
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Human-readable report block.
|
|
237
|
+
* @param {{dryRun:boolean, decayed:number, archived:number, purged:number, rawLogFiles:string[], migrationWarning?:boolean}} result
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
function formatReport(result) {
|
|
241
|
+
const mode = result.dryRun ? 'DRY-RUN (no files written)' : 'APPLIED';
|
|
242
|
+
const lines = [
|
|
243
|
+
'Memory Prune Report',
|
|
244
|
+
` Mode: ${mode}`,
|
|
245
|
+
` Decayed: ${result.decayed}`,
|
|
246
|
+
` Archived: ${result.archived}`,
|
|
247
|
+
` Purged: ${result.purged} raw-log file(s)`,
|
|
248
|
+
];
|
|
249
|
+
if (result.rawLogFiles && result.rawLogFiles.length > 0) {
|
|
250
|
+
lines.push(' Raw logs targeted:');
|
|
251
|
+
for (const f of result.rawLogFiles) lines.push(` - ${path.basename(f)}`);
|
|
252
|
+
}
|
|
253
|
+
if (result.migrationWarning) {
|
|
254
|
+
lines.push('');
|
|
255
|
+
lines.push('Warning: archive count dwarfs decay count — likely a first-run migration');
|
|
256
|
+
lines.push(' of pre-F-055 memory files (missing last_seen, treated as Infinity-age).');
|
|
257
|
+
lines.push(' Review archived entries before committing.');
|
|
258
|
+
}
|
|
259
|
+
if (result.dryRun) {
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push('Rerun with --apply to commit these changes.');
|
|
262
|
+
}
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Single-line JSONL record for prune-log.jsonl.
|
|
268
|
+
* @cap-todo(ac:F-056/AC-6)
|
|
269
|
+
* @param {{dryRun:boolean, decayed:number, archived:number, purged:number, archiveFile?:string|null, errors?:Array}} result
|
|
270
|
+
* @param {Date} now
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
function formatPruneLogEntry(result, now) {
|
|
274
|
+
const payload = {
|
|
275
|
+
timestamp: (now instanceof Date ? now : new Date()).toISOString(),
|
|
276
|
+
dryRun: !!result.dryRun,
|
|
277
|
+
decayed: result.decayed | 0,
|
|
278
|
+
archived: result.archived | 0,
|
|
279
|
+
purged: result.purged | 0,
|
|
280
|
+
// Additive (new in review follow-up): keep the payload shape extensible without
|
|
281
|
+
// breaking older log-consumers that read only the original keys.
|
|
282
|
+
archiveFile: result.archiveFile ? path.basename(result.archiveFile) : null,
|
|
283
|
+
errorCount: Array.isArray(result.errors) ? result.errors.length : 0,
|
|
284
|
+
};
|
|
285
|
+
return JSON.stringify(payload) + '\n';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Normalise an Error-or-string into a short, log-safe message string.
|
|
290
|
+
* @param {unknown} err
|
|
291
|
+
* @returns {string}
|
|
292
|
+
*/
|
|
293
|
+
function errorMessage(err) {
|
|
294
|
+
if (!err) return '';
|
|
295
|
+
if (typeof err === 'string') return err;
|
|
296
|
+
if (err && typeof err.message === 'string') return err.message;
|
|
297
|
+
return String(err);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- Archive writing ---
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Format a single archived entry as a markdown block for the archive file.
|
|
304
|
+
* @param {MemoryEntry} entry
|
|
305
|
+
* @param {Date} now
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
function formatArchivedEntry(entry, now) {
|
|
309
|
+
const meta = confidence.ensureFields(entry.metadata);
|
|
310
|
+
const safeContent = String(entry.content || '').replace(/[\r\n]+/g, ' ');
|
|
311
|
+
const files = meta.relatedFiles?.length > 0
|
|
312
|
+
? meta.relatedFiles.map((f) => `\`${f}\``).join(', ')
|
|
313
|
+
: 'cross-cutting';
|
|
314
|
+
const features = meta.features?.length > 0 ? ` (${meta.features.join(', ')})` : '';
|
|
315
|
+
const date = meta.source ? String(meta.source).substring(0, 10) : 'unknown';
|
|
316
|
+
const archivedAt = (now instanceof Date ? now : new Date()).toISOString();
|
|
317
|
+
return [
|
|
318
|
+
`### ${safeContent}`,
|
|
319
|
+
`- **Category:** ${entry.category}`,
|
|
320
|
+
`- **Date:** ${date}${features}`,
|
|
321
|
+
`- **Files:** ${files}`,
|
|
322
|
+
`- **Confidence:** ${meta.confidence.toFixed(2)}`,
|
|
323
|
+
`- **Evidence:** ${meta.evidence_count}`,
|
|
324
|
+
`- **Last Seen:** ${meta.last_seen}`,
|
|
325
|
+
`- **Archived At:** ${archivedAt}`,
|
|
326
|
+
'',
|
|
327
|
+
].join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Append-or-create archive markdown for the current archival month.
|
|
332
|
+
* Idempotent: multiple runs in the same month append to the same file.
|
|
333
|
+
* @cap-todo(ac:F-056/AC-4)
|
|
334
|
+
* @param {string} archiveDir
|
|
335
|
+
* @param {MemoryEntry[]} archivedEntries
|
|
336
|
+
* @param {Date} now
|
|
337
|
+
* @returns {string|null} filepath written, or null if nothing to archive
|
|
338
|
+
*/
|
|
339
|
+
function writeArchive(archiveDir, archivedEntries, now) {
|
|
340
|
+
if (!archivedEntries || archivedEntries.length === 0) return null;
|
|
341
|
+
const d = now instanceof Date ? now : new Date();
|
|
342
|
+
const yyyy = d.getUTCFullYear();
|
|
343
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
344
|
+
const filename = `${yyyy}-${mm}.md`;
|
|
345
|
+
const filepath = path.join(archiveDir, filename);
|
|
346
|
+
|
|
347
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
348
|
+
|
|
349
|
+
let body = '';
|
|
350
|
+
if (!fs.existsSync(filepath)) {
|
|
351
|
+
body += `# Memory Archive: ${yyyy}-${mm}\n\n`;
|
|
352
|
+
body += '> Entries archived from project memory because they fell below confidence and age thresholds.\n';
|
|
353
|
+
body += '> Archive is additive — entries can be appended but are not mutated in place.\n\n';
|
|
354
|
+
}
|
|
355
|
+
for (const entry of archivedEntries) {
|
|
356
|
+
body += formatArchivedEntry(entry, d);
|
|
357
|
+
}
|
|
358
|
+
fs.appendFileSync(filepath, body, 'utf8');
|
|
359
|
+
return filepath;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Main entry point ---
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Classify entries across all decay-eligible category files.
|
|
366
|
+
* @param {string} memDir
|
|
367
|
+
* @param {Date} now
|
|
368
|
+
* @returns {{perCategoryKept:Object, allDecayed:Array, allArchived:MemoryEntry[], errors:Array}}
|
|
369
|
+
*/
|
|
370
|
+
function classifyPhase(memDir, now) {
|
|
371
|
+
const allDecayed = [];
|
|
372
|
+
const allArchived = [];
|
|
373
|
+
const perCategoryKept = {};
|
|
374
|
+
const errors = [];
|
|
375
|
+
|
|
376
|
+
for (const category of DECAY_CATEGORIES) {
|
|
377
|
+
try {
|
|
378
|
+
const fp = path.join(memDir, CATEGORY_FILES[category]);
|
|
379
|
+
const { entries } = readMemoryFile(fp);
|
|
380
|
+
const enriched = entries.map((e) => ({ category, content: e.content, metadata: e.metadata }));
|
|
381
|
+
const { kept, decayed, archived } = classifyEntries(enriched, now);
|
|
382
|
+
perCategoryKept[category] = kept;
|
|
383
|
+
for (const d of decayed) allDecayed.push(d);
|
|
384
|
+
for (const a of archived) allArchived.push(a);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
errors.push({ stage: `classify:${category}`, message: errorMessage(err) });
|
|
387
|
+
perCategoryKept[category] = [];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { perCategoryKept, allDecayed, allArchived, errors };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Apply the side-effects of a prune run: write archive, rewrite memory files,
|
|
396
|
+
* purge stale raw logs, append the prune-log record. Each stage captures its
|
|
397
|
+
* own errors into `errors`; archive/memory failures short-circuit the
|
|
398
|
+
* remainder to avoid partial rewrites.
|
|
399
|
+
*
|
|
400
|
+
* @param {{projectRoot:string, archiveDir:string, pruneLogPath:string, rawLogFiles:string[], allArchived:MemoryEntry[], perCategoryKept:Object, now:Date}} ctx
|
|
401
|
+
* @param {Object} result - mutated in place with final counts + archiveFile
|
|
402
|
+
* @param {Array} errors - mutated in place with per-stage failures
|
|
403
|
+
* @returns {void}
|
|
404
|
+
*/
|
|
405
|
+
function applySideEffects(ctx, result, errors) {
|
|
406
|
+
const { projectRoot, archiveDir, pruneLogPath, rawLogFiles, allArchived, perCategoryKept, now } = ctx;
|
|
407
|
+
|
|
408
|
+
const entriesToWrite = [];
|
|
409
|
+
for (const category of DECAY_CATEGORIES) {
|
|
410
|
+
for (const e of perCategoryKept[category] || []) {
|
|
411
|
+
entriesToWrite.push({ category, content: e.content, file: e.file, metadata: e.metadata });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Archive first so a failed archive leaves live memory intact and archived
|
|
416
|
+
// entries remain recoverable on the next run. If archive succeeds but memory
|
|
417
|
+
// rewrite fails, we re-archive idempotent duplicates — never data loss.
|
|
418
|
+
try {
|
|
419
|
+
result.archiveFile = writeArchive(archiveDir, allArchived, now);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
errors.push({ stage: 'write-archive', message: errorMessage(err) });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
writeMemoryDirectory(projectRoot, entriesToWrite);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
errors.push({ stage: 'write-memory', message: errorMessage(err) });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const purgedOk = [];
|
|
433
|
+
for (const f of rawLogFiles) {
|
|
434
|
+
try {
|
|
435
|
+
fs.unlinkSync(f);
|
|
436
|
+
purgedOk.push(f);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
errors.push({ stage: 'unlink-raw-log', message: `${path.basename(f)}: ${errorMessage(err)}` });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
result.purgedFiles = purgedOk;
|
|
442
|
+
result.purged = purgedOk.length;
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
fs.mkdirSync(path.dirname(pruneLogPath), { recursive: true });
|
|
446
|
+
fs.appendFileSync(pruneLogPath, formatPruneLogEntry(result, now), 'utf8');
|
|
447
|
+
} catch (err) {
|
|
448
|
+
errors.push({ stage: 'append-prune-log', message: errorMessage(err) });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Prune project memory: decay stale entries, archive very-stale low-confidence ones,
|
|
454
|
+
* purge old raw-event-log files.
|
|
455
|
+
*
|
|
456
|
+
* @cap-todo(ac:F-056/AC-1)
|
|
457
|
+
* @cap-todo(ac:F-056/AC-2)
|
|
458
|
+
* @cap-todo(ac:F-056/AC-6)
|
|
459
|
+
* @param {string} projectRoot
|
|
460
|
+
* @param {{apply?:boolean, now?:Date}} [options]
|
|
461
|
+
* @returns {{dryRun:boolean, decayed:number, archived:number, purged:number, decayedEntries:Array, archivedEntries:MemoryEntry[], purgedFiles:string[], rawLogFiles:string[], archiveFile:string|null, migrationWarning:boolean, errors:Array<{stage:string, message:string}>}}
|
|
462
|
+
*/
|
|
463
|
+
function prune(projectRoot, options = {}) {
|
|
464
|
+
const now = options.now instanceof Date ? options.now : new Date();
|
|
465
|
+
const apply = options.apply === true;
|
|
466
|
+
|
|
467
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
468
|
+
const rawDir = path.join(projectRoot, ...RAW_LOG_DIR_PARTS);
|
|
469
|
+
const archiveDir = path.join(projectRoot, ...ARCHIVE_DIR_PARTS);
|
|
470
|
+
const pruneLogPath = path.join(projectRoot, ...PRUNE_LOG_PARTS);
|
|
471
|
+
|
|
472
|
+
const { perCategoryKept, allDecayed, allArchived, errors } = classifyPhase(memDir, now);
|
|
473
|
+
|
|
474
|
+
// selectStaleRawLogs already swallows its own I/O errors (empty/missing dir,
|
|
475
|
+
// readdir failure) and returns []. Wrapping it in an outer try/catch was
|
|
476
|
+
// redundant dead code — the inner handler is authoritative.
|
|
477
|
+
const rawLogFiles = selectStaleRawLogs(rawDir, now, RAW_LOG_RETENTION_DAYS);
|
|
478
|
+
|
|
479
|
+
// Migration warning: if a run archives dramatically more than it decays,
|
|
480
|
+
// the most likely cause is pre-F-055 memory files that lack last_seen —
|
|
481
|
+
// classifyEntries then treats them as Infinity-age and archives wholesale.
|
|
482
|
+
// Flag so the CLI can surface a "looks like a first-run migration" hint.
|
|
483
|
+
const migrationWarning = allArchived.length >= 5 && allArchived.length > allDecayed.length * 4;
|
|
484
|
+
|
|
485
|
+
const result = {
|
|
486
|
+
dryRun: !apply,
|
|
487
|
+
decayed: allDecayed.length,
|
|
488
|
+
archived: allArchived.length,
|
|
489
|
+
purged: rawLogFiles.length,
|
|
490
|
+
decayedEntries: allDecayed,
|
|
491
|
+
archivedEntries: allArchived,
|
|
492
|
+
purgedFiles: rawLogFiles.slice(),
|
|
493
|
+
rawLogFiles: rawLogFiles.slice(),
|
|
494
|
+
archiveFile: null,
|
|
495
|
+
migrationWarning,
|
|
496
|
+
errors,
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
if (!apply) return result;
|
|
500
|
+
|
|
501
|
+
applySideEffects(
|
|
502
|
+
{ projectRoot, archiveDir, pruneLogPath, rawLogFiles, allArchived, perCategoryKept, now },
|
|
503
|
+
result,
|
|
504
|
+
errors,
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// F-086/AC-3: pruneGitignored — clean already-existing memory files of entries
|
|
512
|
+
// whose related-files would now be excluded by the scope filter. Useful for
|
|
513
|
+
// projects that bootstrapped with a pre-F-085 CAP version and accumulated
|
|
514
|
+
// build-output decisions / bundle-artefact references in their memory files.
|
|
515
|
+
|
|
516
|
+
// @cap-todo(ac:F-086/AC-3) V6 platform-memory bullet pattern: bullet line ending with a
|
|
517
|
+
// backtick-wrapped path:linenum reference. We extract the path, drop the line if the
|
|
518
|
+
// scope filter would exclude it. Auto-block markers (cap:auto:start / end) and headings
|
|
519
|
+
// are preserved verbatim.
|
|
520
|
+
const V6_BULLET_PATH_RE = /`([^`\s][^`]*?)(?::\d+)?`\s*$/;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Scan V5 monolith files (decisions/pitfalls/patterns/hotspots) and V6 platform/feature
|
|
524
|
+
* files for entries whose source file is now out-of-scope per the scope filter.
|
|
525
|
+
*
|
|
526
|
+
* Default behaviour is dry-run; pass `apply: true` to rewrite the files. Returns counts
|
|
527
|
+
* + per-file diffs so callers can render a report before committing.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} projectRoot
|
|
530
|
+
* @param {{apply?: boolean, scope?: import('./cap-scope-filter.cjs').ScopeFilter}} [options]
|
|
531
|
+
* @returns {{
|
|
532
|
+
* dryRun: boolean,
|
|
533
|
+
* v5RemovedTotal: number,
|
|
534
|
+
* v6RemovedTotal: number,
|
|
535
|
+
* v5Files: Array<{file: string, removed: string[], kept: number}>,
|
|
536
|
+
* v6Files: Array<{file: string, removed: string[], kept: number}>,
|
|
537
|
+
* errors: Array<{stage: string, message: string}>
|
|
538
|
+
* }}
|
|
539
|
+
*/
|
|
540
|
+
function pruneGitignored(projectRoot, options) {
|
|
541
|
+
const opts = options || {};
|
|
542
|
+
const apply = opts.apply === true;
|
|
543
|
+
const scope = opts.scope || scopeModule.buildScopeFilter(projectRoot);
|
|
544
|
+
|
|
545
|
+
const result = {
|
|
546
|
+
dryRun: !apply,
|
|
547
|
+
v5RemovedTotal: 0,
|
|
548
|
+
v6RemovedTotal: 0,
|
|
549
|
+
v5Files: [],
|
|
550
|
+
v6Files: [],
|
|
551
|
+
errors: [],
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// ---- V5 monolith files ----
|
|
555
|
+
for (const [, filename] of Object.entries(CATEGORY_FILES)) {
|
|
556
|
+
const fp = path.join(projectRoot, MEMORY_DIR, filename);
|
|
557
|
+
if (!fs.existsSync(fp)) continue;
|
|
558
|
+
try {
|
|
559
|
+
const { entries } = readMemoryFile(fp);
|
|
560
|
+
const kept = [];
|
|
561
|
+
const removed = [];
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
const files = (entry.metadata && Array.isArray(entry.metadata.relatedFiles)) ? entry.metadata.relatedFiles : [];
|
|
564
|
+
// Drop entry only when ALL related files are out-of-scope (and there's at least one to judge by).
|
|
565
|
+
if (files.length > 0 && files.every((f) => scope.isExcluded(path.resolve(projectRoot, f), false))) {
|
|
566
|
+
removed.push(entry.content);
|
|
567
|
+
} else {
|
|
568
|
+
kept.push(entry);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
result.v5RemovedTotal += removed.length;
|
|
572
|
+
result.v5Files.push({ file: path.relative(projectRoot, fp), removed, kept: kept.length });
|
|
573
|
+
if (apply && removed.length > 0) {
|
|
574
|
+
// Re-render via writeMemoryDirectory in non-merge mode (full overwrite of this file's category).
|
|
575
|
+
// The `category` is derived from the filename via reverse-lookup.
|
|
576
|
+
const category = Object.entries(CATEGORY_FILES).find(([, fn]) => fn === filename)[0];
|
|
577
|
+
const re = require('./cap-memory-dir.cjs');
|
|
578
|
+
const md = re.generateCategoryMarkdown
|
|
579
|
+
? re.generateCategoryMarkdown(category, kept.map((e) => ({ category, ...e })))
|
|
580
|
+
: null;
|
|
581
|
+
if (md != null) {
|
|
582
|
+
const tmp = fp + '.tmp';
|
|
583
|
+
fs.writeFileSync(tmp, md, 'utf8');
|
|
584
|
+
fs.renameSync(tmp, fp);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
result.errors.push({ stage: `v5:${filename}`, message: errorMessage(err) });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---- V6 platform/feature files ----
|
|
593
|
+
// Walk .cap/memory/platform/*.md and .cap/memory/features/*.md
|
|
594
|
+
const v6Dirs = [
|
|
595
|
+
path.join(projectRoot, MEMORY_DIR, 'platform'),
|
|
596
|
+
path.join(projectRoot, MEMORY_DIR, 'features'),
|
|
597
|
+
];
|
|
598
|
+
for (const dir of v6Dirs) {
|
|
599
|
+
if (!fs.existsSync(dir)) continue;
|
|
600
|
+
let entries;
|
|
601
|
+
try {
|
|
602
|
+
entries = fs.readdirSync(dir);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
result.errors.push({ stage: `v6:readdir:${path.relative(projectRoot, dir)}`, message: errorMessage(err) });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
for (const name of entries) {
|
|
608
|
+
if (!name.endsWith('.md')) continue;
|
|
609
|
+
const fp = path.join(dir, name);
|
|
610
|
+
try {
|
|
611
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
612
|
+
const lines = raw.split('\n');
|
|
613
|
+
const newLines = [];
|
|
614
|
+
const removed = [];
|
|
615
|
+
let kept = 0;
|
|
616
|
+
for (const line of lines) {
|
|
617
|
+
const isBullet = /^- /.test(line);
|
|
618
|
+
if (isBullet) {
|
|
619
|
+
const match = V6_BULLET_PATH_RE.exec(line);
|
|
620
|
+
if (match) {
|
|
621
|
+
const filePath = match[1];
|
|
622
|
+
if (scope.isExcluded(path.resolve(projectRoot, filePath), false)) {
|
|
623
|
+
removed.push(line.trim());
|
|
624
|
+
continue; // drop this line
|
|
625
|
+
}
|
|
626
|
+
kept++;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
newLines.push(line);
|
|
630
|
+
}
|
|
631
|
+
result.v6RemovedTotal += removed.length;
|
|
632
|
+
result.v6Files.push({ file: path.relative(projectRoot, fp), removed, kept });
|
|
633
|
+
if (apply && removed.length > 0) {
|
|
634
|
+
const tmp = fp + '.tmp';
|
|
635
|
+
fs.writeFileSync(tmp, newLines.join('\n'), 'utf8');
|
|
636
|
+
fs.renameSync(tmp, fp);
|
|
637
|
+
}
|
|
638
|
+
} catch (err) {
|
|
639
|
+
result.errors.push({ stage: `v6:${path.relative(projectRoot, fp)}`, message: errorMessage(err) });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Format a prune-gitignored result as a human-readable report.
|
|
649
|
+
* @param {ReturnType<typeof pruneGitignored>} result
|
|
650
|
+
* @returns {string}
|
|
651
|
+
*/
|
|
652
|
+
function formatGitignoredReport(result) {
|
|
653
|
+
const lines = [];
|
|
654
|
+
lines.push(`cap:memory prune --gitignored ${result.dryRun ? '(dry-run)' : '(applied)'}`);
|
|
655
|
+
lines.push(` V5 entries removed: ${result.v5RemovedTotal}`);
|
|
656
|
+
lines.push(` V6 lines removed: ${result.v6RemovedTotal}`);
|
|
657
|
+
if (result.v5Files.length > 0) {
|
|
658
|
+
const dirty = result.v5Files.filter((f) => f.removed.length > 0);
|
|
659
|
+
if (dirty.length > 0) {
|
|
660
|
+
lines.push('');
|
|
661
|
+
lines.push('V5 files affected:');
|
|
662
|
+
for (const f of dirty) lines.push(` ${f.file} — ${f.removed.length} removed, ${f.kept} kept`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (result.v6Files.length > 0) {
|
|
666
|
+
const dirty = result.v6Files.filter((f) => f.removed.length > 0);
|
|
667
|
+
if (dirty.length > 0) {
|
|
668
|
+
lines.push('');
|
|
669
|
+
lines.push('V6 files affected:');
|
|
670
|
+
for (const f of dirty) lines.push(` ${f.file} — ${f.removed.length} removed, ${f.kept} kept`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (result.errors.length > 0) {
|
|
674
|
+
lines.push('');
|
|
675
|
+
lines.push('Errors:');
|
|
676
|
+
for (const e of result.errors) lines.push(` ${e.stage}: ${e.message}`);
|
|
677
|
+
}
|
|
678
|
+
return lines.join('\n');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
module.exports = {
|
|
682
|
+
DECAY_START_DAYS,
|
|
683
|
+
DECAY_STEP_DAYS,
|
|
684
|
+
DECAY_AMOUNT,
|
|
685
|
+
ARCHIVE_CONFIDENCE_THRESHOLD,
|
|
686
|
+
ARCHIVE_AGE_DAYS,
|
|
687
|
+
RAW_LOG_RETENTION_DAYS,
|
|
688
|
+
CONFIDENCE_FLOOR,
|
|
689
|
+
|
|
690
|
+
daysBetween,
|
|
691
|
+
computeDecay,
|
|
692
|
+
shouldArchive,
|
|
693
|
+
classifyEntries,
|
|
694
|
+
selectStaleRawLogs,
|
|
695
|
+
formatReport,
|
|
696
|
+
formatPruneLogEntry,
|
|
697
|
+
formatArchivedEntry,
|
|
698
|
+
writeArchive,
|
|
699
|
+
// Exported for unit testing — not part of the CLI surface.
|
|
700
|
+
classifyPhase,
|
|
701
|
+
applySideEffects,
|
|
702
|
+
errorMessage,
|
|
703
|
+
prune,
|
|
704
|
+
// F-086/AC-3
|
|
705
|
+
pruneGitignored,
|
|
706
|
+
formatGitignoredReport,
|
|
707
|
+
};
|