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,987 @@
|
|
|
1
|
+
// @cap-feature(feature:F-029) Cross-File Memory Directory — write aggregated memory to .cap/memory/ markdown files
|
|
2
|
+
// @cap-decision .cap/memory/ is git-tracked (not gitignored) — project memory persists across clones and team members.
|
|
3
|
+
// @cap-decision Stable anchor IDs derived from content hash — cross-reference links survive regeneration.
|
|
4
|
+
// @cap-constraint Zero external dependencies — uses only Node.js built-ins.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// @cap-history(sessions:6, edits:23, since:2026-04-20, learned:2026-05-08) Frequently modified — 6 sessions, 23 edits
|
|
9
|
+
// @cap-history(sessions:2, edits:3, since:2026-04-20, learned:2026-04-21) Frequently modified — 2 sessions, 3 edits
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
const confidence = require('./cap-memory-confidence.cjs');
|
|
14
|
+
|
|
15
|
+
// --- Constants ---
|
|
16
|
+
|
|
17
|
+
const MEMORY_DIR = path.join('.cap', 'memory');
|
|
18
|
+
|
|
19
|
+
const CATEGORY_FILES = {
|
|
20
|
+
decision: 'decisions.md',
|
|
21
|
+
hotspot: 'hotspots.md',
|
|
22
|
+
pitfall: 'pitfalls.md',
|
|
23
|
+
pattern: 'patterns.md',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// --- Anchor Generation (AC-6) ---
|
|
27
|
+
|
|
28
|
+
// @cap-todo(ref:F-029:AC-6) Generate stable anchor IDs so cross-reference links remain valid across regenerations
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a stable anchor ID from entry content.
|
|
32
|
+
* Uses first 8 chars of SHA-256 hash of normalized content.
|
|
33
|
+
* @param {string} content
|
|
34
|
+
* @returns {string} Anchor ID (e.g., "a3f2b1c0")
|
|
35
|
+
*/
|
|
36
|
+
function generateAnchorId(content) {
|
|
37
|
+
const normalized = content.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
38
|
+
return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 8);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Markdown Generation ---
|
|
42
|
+
|
|
43
|
+
// @cap-todo(ref:F-029:AC-1) Write to .cap/memory/ as four markdown files
|
|
44
|
+
// @cap-todo(ref:F-029:AC-3) Each entry includes source session date, related files, summary
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate markdown content for a memory category file.
|
|
48
|
+
* @param {string} category
|
|
49
|
+
* @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
|
|
50
|
+
* @param {{ minConfidence?: number }} [opts] - F-090: confidence threshold (default 0.6)
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function generateCategoryMarkdown(category, entries, opts = {}) {
|
|
54
|
+
const title = category.charAt(0).toUpperCase() + category.slice(1) + 's';
|
|
55
|
+
const out = [];
|
|
56
|
+
out.push(`# Project Memory: ${title}`);
|
|
57
|
+
out.push('');
|
|
58
|
+
out.push(`> Auto-generated from code tags and session data. Pinned entries are preserved; others may be updated on regeneration.`);
|
|
59
|
+
out.push(`> Last updated: ${new Date().toISOString().substring(0, 10)}`);
|
|
60
|
+
out.push('');
|
|
61
|
+
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
out.push(`_No ${category}s recorded yet._`);
|
|
64
|
+
return out.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (category === 'hotspot') {
|
|
68
|
+
return generateHotspotsMarkdown(out, entries);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// @cap-feature(feature:F-090, primary:true) Confidence-filter: drop low-signal entries from
|
|
72
|
+
// the .md output so agents reading the file at session-start don't ingest 568 KB of
|
|
73
|
+
// Confidence:0.50/Evidence:1 heuristic-extracted comment text. graph.json stays full
|
|
74
|
+
// (Cluster/Affinity components need every node); the filter only reduces the human/agent-
|
|
75
|
+
// readable .md surface.
|
|
76
|
+
// @cap-decision(F-090/separation-of-concerns) generateCategoryMarkdown defaults to 0 (no filter)
|
|
77
|
+
// so it stays a pure rendering function — render-correctness tests don't have to think about
|
|
78
|
+
// the filter. writeMemoryDirectory (the pipeline entry point) defaults to 0.6 to apply the
|
|
79
|
+
// policy. Callers who want filtered rendering pass minConfidence explicitly.
|
|
80
|
+
const minConfidence =
|
|
81
|
+
typeof opts.minConfidence === 'number' ? opts.minConfidence : 0;
|
|
82
|
+
const filtered = _filterEntriesForOutput(entries, { minConfidence });
|
|
83
|
+
const droppedCount = entries.length - filtered.length;
|
|
84
|
+
|
|
85
|
+
if (filtered.length === 0) {
|
|
86
|
+
out.push(`_No high-confidence ${category}s recorded yet (filtered out ${droppedCount} low-confidence ${category}s)._`);
|
|
87
|
+
return out.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Default: list format for decisions, pitfalls, patterns
|
|
91
|
+
for (const entry of filtered) {
|
|
92
|
+
const anchor = generateAnchorId(entry.content);
|
|
93
|
+
// Newlines or CRs in entry content would fracture into phantom entries on the next readMemoryFile pass and could smuggle a fake anchor heading. Collapse on the write path so the Markdown grammar stays one-entry-per-heading.
|
|
94
|
+
const safeContent = String(entry.content).replace(/[\r\n]+/g, ' ');
|
|
95
|
+
const pinTag = entry.metadata.pinned ? ' **[pinned]**' : '';
|
|
96
|
+
const date = entry.metadata.source ? entry.metadata.source.substring(0, 10) : 'unknown';
|
|
97
|
+
const files = entry.metadata.relatedFiles?.length > 0
|
|
98
|
+
? entry.metadata.relatedFiles.map(f => `\`${f}\``).join(', ')
|
|
99
|
+
: 'cross-cutting';
|
|
100
|
+
const features = entry.metadata.features?.length > 0
|
|
101
|
+
? ` (${entry.metadata.features.join(', ')})`
|
|
102
|
+
: '';
|
|
103
|
+
|
|
104
|
+
// @cap-todo(ac:F-055/AC-1) Confidence + evidence_count rendered as entry-block bullets.
|
|
105
|
+
// @cap-todo(ac:F-055/AC-3) ensureFields supplies defaults for entries that predate F-055.
|
|
106
|
+
const fields = confidence.ensureFields(entry.metadata);
|
|
107
|
+
// @cap-todo(ac:F-055/AC-6) Entries with confidence<0.3 render as a blockquote prefixed with "*(low confidence)*".
|
|
108
|
+
const dim = confidence.isLowConfidence(entry.metadata);
|
|
109
|
+
const prefix = dim ? '> ' : '';
|
|
110
|
+
const dimMarker = dim ? '*(low confidence)* ' : '';
|
|
111
|
+
|
|
112
|
+
out.push(`${prefix}### <a id="${anchor}"></a>${dimMarker}${safeContent}${pinTag}`);
|
|
113
|
+
out.push(dim ? '>' : '');
|
|
114
|
+
out.push(`${prefix}- **Date:** ${date}${features}`);
|
|
115
|
+
out.push(`${prefix}- **Files:** ${files}`);
|
|
116
|
+
out.push(`${prefix}- **Confidence:** ${fields.confidence.toFixed(2)}`);
|
|
117
|
+
out.push(`${prefix}- **Evidence:** ${fields.evidence_count}`);
|
|
118
|
+
// @cap-todo(ac:F-056/AC-3) Last Seen bullet written so the decay clock roundtrips through disk.
|
|
119
|
+
out.push(`${prefix}- **Last Seen:** ${fields.last_seen}`);
|
|
120
|
+
if (entry.metadata.confirmations) {
|
|
121
|
+
out.push(`${prefix}- **Confirmed:** ${entry.metadata.confirmations} times`);
|
|
122
|
+
}
|
|
123
|
+
out.push('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
out.push(`---`);
|
|
127
|
+
// @cap-feature(feature:F-090) Footer-Counter shows kept + filtered counts so the user can
|
|
128
|
+
// tell at a glance how aggressive the confidence filter was on this run.
|
|
129
|
+
if (droppedCount > 0) {
|
|
130
|
+
out.push(`*${filtered.length} ${category}s kept (filtered out ${droppedCount} low-confidence ${category}s; threshold=${minConfidence})*`);
|
|
131
|
+
} else {
|
|
132
|
+
out.push(`*${filtered.length} ${category}s total*`);
|
|
133
|
+
}
|
|
134
|
+
return out.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// @cap-feature(feature:F-090) Pure filter for V5 monolithic Memory output.
|
|
138
|
+
// @cap-decision(F-090/AC-1) Filter rule: keep entry IFF (pinned OR confidence >= threshold).
|
|
139
|
+
// evidence_count is implicit in confidence (each re-observation +0.1, default 0.5), so a
|
|
140
|
+
// single check on confidence captures both "trustworthy" (high confidence) and "user-curated"
|
|
141
|
+
// (pinned) signals. evidence-only entries without re-observation are noise by definition.
|
|
142
|
+
// @cap-decision(F-090/AC-5) Defense-in-depth: pinned wins regardless of confidence value.
|
|
143
|
+
// A pinned entry with confidence:0.0 (e.g. user-suppressed via contradiction) is still
|
|
144
|
+
// user-curated content and must round-trip to disk.
|
|
145
|
+
/**
|
|
146
|
+
* Filter memory entries for .md output. graph.json is built independently and not affected.
|
|
147
|
+
* @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
|
|
148
|
+
* @param {{ minConfidence: number }} options
|
|
149
|
+
* @returns {import('./cap-memory-engine.cjs').MemoryEntry[]}
|
|
150
|
+
*/
|
|
151
|
+
function _filterEntriesForOutput(entries, options) {
|
|
152
|
+
const threshold = options.minConfidence;
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry || !entry.metadata) continue;
|
|
156
|
+
if (entry.metadata.pinned === true) { out.push(entry); continue; }
|
|
157
|
+
const fields = confidence.ensureFields(entry.metadata);
|
|
158
|
+
if (typeof fields.confidence === 'number' && fields.confidence >= threshold) {
|
|
159
|
+
out.push(entry);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// @cap-todo(ref:F-029:AC-4) hotspots.md ranks files by cross-session edit frequency
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Generate hotspots markdown with ranking table.
|
|
169
|
+
* @param {string[]} out - Output lines (header already added)
|
|
170
|
+
* @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function generateHotspotsMarkdown(out, entries) {
|
|
174
|
+
// Sort by sessions desc, then edits desc
|
|
175
|
+
const sorted = [...entries].sort((a, b) => {
|
|
176
|
+
const sDiff = (b.metadata.sessions || 0) - (a.metadata.sessions || 0);
|
|
177
|
+
if (sDiff !== 0) return sDiff;
|
|
178
|
+
return (b.metadata.edits || 0) - (a.metadata.edits || 0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
out.push('| Rank | File | Sessions | Edits | Since |');
|
|
182
|
+
out.push('|------|------|----------|-------|-------|');
|
|
183
|
+
|
|
184
|
+
// Newlines or stray pipes in entry.file / metadata.since would fracture the
|
|
185
|
+
// markdown table into invalid rows. Parallel to the list-writer in
|
|
186
|
+
// generateCategoryMarkdown that collapses \r\n in entry.content.
|
|
187
|
+
const cellSanitize = (v) => String(v ?? '?').replace(/[\r\n]+/g, ' ').replace(/\|/g, '\\|');
|
|
188
|
+
|
|
189
|
+
sorted.forEach((entry, i) => {
|
|
190
|
+
const anchor = generateAnchorId(entry.content + entry.file);
|
|
191
|
+
const file = cellSanitize(entry.file);
|
|
192
|
+
const sessions = cellSanitize(entry.metadata.sessions || '?');
|
|
193
|
+
const edits = cellSanitize(entry.metadata.edits || '?');
|
|
194
|
+
const since = cellSanitize(entry.metadata.since || '?');
|
|
195
|
+
out.push(`| <a id="${anchor}"></a>${i + 1} | \`${file}\` | ${sessions} | ${edits} | ${since} |`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
out.push('');
|
|
199
|
+
out.push(`---`);
|
|
200
|
+
out.push(`*${entries.length} hotspots total*`);
|
|
201
|
+
return out.join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- File I/O ---
|
|
205
|
+
|
|
206
|
+
// @cap-todo(ref:F-029:AC-2) Auto-generated — manual edits outside pinned entries overwritten
|
|
207
|
+
// @cap-todo(ref:F-029:AC-7) .cap/memory/ is git-committable (not gitignored)
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse existing memory entries from a markdown file to support merging.
|
|
211
|
+
* Extracts anchor IDs to detect already-known entries.
|
|
212
|
+
* @param {string} content - Markdown file content
|
|
213
|
+
* @returns {Set<string>} Set of anchor IDs already present
|
|
214
|
+
*/
|
|
215
|
+
function parseExistingAnchors(content) {
|
|
216
|
+
const anchors = new Set();
|
|
217
|
+
const re = /<a id="([a-f0-9]+)"><\/a>/g;
|
|
218
|
+
let match;
|
|
219
|
+
while ((match = re.exec(content)) !== null) {
|
|
220
|
+
anchors.add(match[1]);
|
|
221
|
+
}
|
|
222
|
+
return anchors;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Write all memory category files to .cap/memory/.
|
|
227
|
+
* Supports merge mode: new entries are added to existing files, duplicates skipped by anchor ID.
|
|
228
|
+
* @param {string} projectRoot - Project root directory
|
|
229
|
+
* @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries - All memory entries
|
|
230
|
+
* @param {Object} [options]
|
|
231
|
+
* @param {boolean} [options.dryRun] - If true, return content without writing
|
|
232
|
+
* @param {boolean} [options.merge] - If true, merge with existing entries instead of overwriting
|
|
233
|
+
* @returns {{files: Object<string, string>, written: number}}
|
|
234
|
+
*/
|
|
235
|
+
function writeMemoryDirectory(projectRoot, entries, options = {}) {
|
|
236
|
+
// @cap-feature(feature:F-093, primary:true) Layout dispatch: V5 monolithic (default) or V6 per-feature.
|
|
237
|
+
// V6 mode is opt-in via .cap/config.json: { memory: { layout: 'v6' } }. Without the flag
|
|
238
|
+
// behaviour is byte-identical to pre-F-093 (legacy callers and tests stay green).
|
|
239
|
+
if (_isV6LayoutEnabled(projectRoot, options)) {
|
|
240
|
+
return _writeMemoryV6(projectRoot, entries, options);
|
|
241
|
+
}
|
|
242
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
243
|
+
const files = {};
|
|
244
|
+
let written = 0;
|
|
245
|
+
|
|
246
|
+
// Group entries by category
|
|
247
|
+
const grouped = { decision: [], hotspot: [], pitfall: [], pattern: [] };
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
const cat = entry.category;
|
|
250
|
+
if (grouped[cat]) grouped[cat].push(entry);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// In merge mode, read existing files and skip entries with matching anchor IDs
|
|
254
|
+
const existingFiles = options.merge ? readMemoryDirectory(projectRoot) : {};
|
|
255
|
+
|
|
256
|
+
for (const [category, categoryEntries] of Object.entries(grouped)) {
|
|
257
|
+
const filename = CATEGORY_FILES[category];
|
|
258
|
+
|
|
259
|
+
// If merging: filter out entries whose anchor already exists
|
|
260
|
+
let entriesToWrite = categoryEntries;
|
|
261
|
+
if (options.merge && existingFiles[filename]) {
|
|
262
|
+
const existingAnchors = parseExistingAnchors(existingFiles[filename]);
|
|
263
|
+
entriesToWrite = categoryEntries.filter(entry => {
|
|
264
|
+
const anchor = category === 'hotspot'
|
|
265
|
+
? generateAnchorId(entry.content + entry.file)
|
|
266
|
+
: generateAnchorId(entry.content);
|
|
267
|
+
return !existingAnchors.has(anchor);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// For hotspots: always regenerate fully (session counts change)
|
|
271
|
+
if (category === 'hotspot') {
|
|
272
|
+
entriesToWrite = categoryEntries;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// @cap-feature(feature:F-090) Forward minConfidence option to the generator. graph.json
|
|
277
|
+
// is built separately from the same entries[] input — no filter applied there.
|
|
278
|
+
// @cap-decision(F-090) Default = 0 (no filter) preserves backwards-compat for direct
|
|
279
|
+
// callers (tests, CLI tools). The HOOK (hooks/cap-memory.js) applies the policy by
|
|
280
|
+
// passing minConfidence:0.6 explicitly — that's where the agent-facing token-cost-of-read
|
|
281
|
+
// problem manifests, so that's where the policy lives.
|
|
282
|
+
const content = generateCategoryMarkdown(
|
|
283
|
+
category,
|
|
284
|
+
category === 'hotspot' ? entriesToWrite : categoryEntries,
|
|
285
|
+
{ minConfidence: options.minConfidence }
|
|
286
|
+
);
|
|
287
|
+
files[filename] = content;
|
|
288
|
+
|
|
289
|
+
if (!options.dryRun) {
|
|
290
|
+
if (!fs.existsSync(memDir)) fs.mkdirSync(memDir, { recursive: true });
|
|
291
|
+
fs.writeFileSync(path.join(memDir, filename), content, 'utf8');
|
|
292
|
+
written++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { files, written };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Read existing memory directory entries (for merging with pinned entries).
|
|
301
|
+
* @param {string} projectRoot
|
|
302
|
+
* @returns {Object<string, string>} filename -> content
|
|
303
|
+
*/
|
|
304
|
+
function readMemoryDirectory(projectRoot) {
|
|
305
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
306
|
+
const result = {};
|
|
307
|
+
|
|
308
|
+
if (!fs.existsSync(memDir)) return result;
|
|
309
|
+
|
|
310
|
+
for (const [, filename] of Object.entries(CATEGORY_FILES)) {
|
|
311
|
+
const fp = path.join(memDir, filename);
|
|
312
|
+
if (fs.existsSync(fp)) {
|
|
313
|
+
result[filename] = fs.readFileSync(fp, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// @cap-todo(ref:F-029:AC-5) Code annotations include cross-reference link to memory file section
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Generate a cross-reference string for an annotation pointing to the memory directory.
|
|
324
|
+
* @param {import('./cap-memory-engine.cjs').MemoryEntry} entry
|
|
325
|
+
* @returns {string} e.g., "see .cap/memory/decisions.md#a3f2b1c0"
|
|
326
|
+
*/
|
|
327
|
+
function getCrossReference(entry) {
|
|
328
|
+
const filename = CATEGORY_FILES[entry.category];
|
|
329
|
+
if (!filename) return '';
|
|
330
|
+
const anchor = entry.category === 'hotspot'
|
|
331
|
+
? generateAnchorId(entry.content + entry.file)
|
|
332
|
+
: generateAnchorId(entry.content);
|
|
333
|
+
return `see .cap/memory/${filename}#${anchor}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Per-file Parser (F-055) ---
|
|
337
|
+
|
|
338
|
+
// @cap-feature(feature:F-055) readMemoryFile parses a single category markdown back into structured entries, applying lazy AC-3 migration for pre-F-055 files.
|
|
339
|
+
// @cap-decision Lightweight line-oriented parser rather than a full markdown AST — the write-side format is fixed and deterministic, so a state machine over bullet prefixes is both sufficient and robust to ad-hoc editing (dim-prefixes, pinned tags).
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parse a single .cap/memory/{category}.md file back into structured entries.
|
|
343
|
+
* Applies ensureFields() on every parsed entry so pre-F-055 files migrate silently (AC-3).
|
|
344
|
+
*
|
|
345
|
+
* Hotspots use a different format (ranking table) and are intentionally not parsed here —
|
|
346
|
+
* the pipeline regenerates them fully each run from session data.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} filePath - Absolute path to a decisions.md / pitfalls.md / patterns.md file
|
|
349
|
+
* @returns {{entries: Array<{content:string, metadata:Object, anchor:string|null}>}}
|
|
350
|
+
*/
|
|
351
|
+
function readMemoryFile(filePath) {
|
|
352
|
+
if (!fs.existsSync(filePath)) return { entries: [] };
|
|
353
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
354
|
+
const lines = raw.split('\n');
|
|
355
|
+
|
|
356
|
+
const entries = [];
|
|
357
|
+
let current = null;
|
|
358
|
+
|
|
359
|
+
const stripQuote = (line) => line.replace(/^>\s?/, '');
|
|
360
|
+
|
|
361
|
+
const flush = () => {
|
|
362
|
+
if (!current) return;
|
|
363
|
+
// @cap-todo(ac:F-055/AC-3) Missing confidence/evidence_count fields get defaulted silently on read.
|
|
364
|
+
current.metadata = confidence.ensureFields(current.metadata);
|
|
365
|
+
entries.push(current);
|
|
366
|
+
current = null;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
for (let rawLine of lines) {
|
|
370
|
+
const quoted = rawLine.startsWith('>');
|
|
371
|
+
const line = quoted ? stripQuote(rawLine) : rawLine;
|
|
372
|
+
|
|
373
|
+
// Heading opens a new entry: "### <a id="HASH"></a>[*(low confidence)* ]Content[ **[pinned]**]"
|
|
374
|
+
const headingMatch = line.match(/^###\s+<a id="([a-f0-9]+)"><\/a>\s*(.*)$/);
|
|
375
|
+
if (headingMatch) {
|
|
376
|
+
flush();
|
|
377
|
+
let title = headingMatch[2].trim();
|
|
378
|
+
// Strip dim marker + pinned suffix from the displayed content.
|
|
379
|
+
const dim = title.startsWith('*(low confidence)*');
|
|
380
|
+
if (dim) title = title.slice('*(low confidence)*'.length).trim();
|
|
381
|
+
const pinned = / \*\*\[pinned\]\*\*\s*$/.test(title);
|
|
382
|
+
title = title.replace(/ \*\*\[pinned\]\*\*\s*$/, '').trim();
|
|
383
|
+
|
|
384
|
+
current = {
|
|
385
|
+
content: title,
|
|
386
|
+
anchor: headingMatch[1],
|
|
387
|
+
metadata: {
|
|
388
|
+
pinned,
|
|
389
|
+
relatedFiles: [],
|
|
390
|
+
features: [],
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!current) continue;
|
|
397
|
+
|
|
398
|
+
// Terminator: a footer rule or the totals line ends the last entry.
|
|
399
|
+
if (/^---\s*$/.test(line) || /^\*\d+\s+\w+s total\*/.test(line)) {
|
|
400
|
+
flush();
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Bullets:
|
|
405
|
+
const dateMatch = line.match(/^-\s+\*\*Date:\*\*\s+(.+?)(?:\s+\((.+?)\))?\s*$/);
|
|
406
|
+
if (dateMatch) {
|
|
407
|
+
const dateStr = dateMatch[1].trim();
|
|
408
|
+
current.metadata.source = dateStr === 'unknown' ? null : dateStr;
|
|
409
|
+
if (dateMatch[2]) {
|
|
410
|
+
current.metadata.features = dateMatch[2].split(',').map((f) => f.trim()).filter(Boolean);
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const filesMatch = line.match(/^-\s+\*\*Files:\*\*\s+(.+?)\s*$/);
|
|
416
|
+
if (filesMatch) {
|
|
417
|
+
const body = filesMatch[1].trim();
|
|
418
|
+
if (body === 'cross-cutting') {
|
|
419
|
+
current.metadata.relatedFiles = [];
|
|
420
|
+
} else {
|
|
421
|
+
current.metadata.relatedFiles = [...body.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
422
|
+
}
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const confMatch = line.match(/^-\s+\*\*Confidence:\*\*\s+([0-9.]+)\s*$/);
|
|
427
|
+
if (confMatch) {
|
|
428
|
+
current.metadata.confidence = Number(confMatch[1]);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const eviMatch = line.match(/^-\s+\*\*Evidence:\*\*\s+(\d+)\s*$/);
|
|
433
|
+
if (eviMatch) {
|
|
434
|
+
current.metadata.evidence_count = Number(eviMatch[1]);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// @cap-todo(ac:F-056/AC-3) Last Seen parsed back; missing values get ensureFields-migrated.
|
|
439
|
+
const lastSeenMatch = line.match(/^-\s+\*\*Last Seen:\*\*\s+(.+?)\s*$/);
|
|
440
|
+
if (lastSeenMatch) {
|
|
441
|
+
current.metadata.last_seen = lastSeenMatch[1].trim();
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const confirmedMatch = line.match(/^-\s+\*\*Confirmed:\*\*\s+(\d+)\s+times\s*$/);
|
|
446
|
+
if (confirmedMatch) {
|
|
447
|
+
current.metadata.confirmations = Number(confirmedMatch[1]);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
flush();
|
|
453
|
+
return { entries };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// =====================================================================
|
|
457
|
+
// F-093: V6 Per-Feature Memory Pipeline Layout
|
|
458
|
+
// =====================================================================
|
|
459
|
+
//
|
|
460
|
+
// @cap-feature(feature:F-093, primary:true) V6 layout opt-in via .cap/config.json
|
|
461
|
+
// { memory: { layout: 'v6' } }. When enabled, writeMemoryDirectory groups entries
|
|
462
|
+
// by feature using F-077's classifier (sourceFileToFeatureId code-tag reverse-index
|
|
463
|
+
// + FEATURE-MAP key_files), writes per-feature files under .cap/memory/features/
|
|
464
|
+
// and platform/, and produces top-level decisions.md/pitfalls.md as Index files.
|
|
465
|
+
//
|
|
466
|
+
// The classifier is shared with F-077 (one-shot migration), so the routing decisions
|
|
467
|
+
// are consistent: a Hub session running incremental V6 yields the same per-feature
|
|
468
|
+
// distribution that F-077 produced from the V5 monolith snapshot.
|
|
469
|
+
//
|
|
470
|
+
// Manual edits in per-feature files are preserved across regeneration via F-076's
|
|
471
|
+
// auto-block markers (<!-- cap:auto:start --> / <!-- cap:auto:end -->).
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @param {string} projectRoot
|
|
475
|
+
* @param {Object} [options]
|
|
476
|
+
* @returns {boolean}
|
|
477
|
+
*/
|
|
478
|
+
function _isV6LayoutEnabled(projectRoot, options) {
|
|
479
|
+
if (options && options.layout === 'v6') return true;
|
|
480
|
+
if (options && options.layout === 'v5') return false;
|
|
481
|
+
if (!projectRoot) return false;
|
|
482
|
+
try {
|
|
483
|
+
const cfgPath = path.join(projectRoot, '.cap', 'config.json');
|
|
484
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
485
|
+
const parsed = JSON.parse(raw);
|
|
486
|
+
return !!(parsed && parsed.memory && parsed.memory.layout === 'v6');
|
|
487
|
+
} catch (_e) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// =====================================================================
|
|
493
|
+
// F-096: Cross-App Memory Aggregation Index
|
|
494
|
+
// =====================================================================
|
|
495
|
+
//
|
|
496
|
+
// @cap-feature(feature:F-096, primary:true) Monorepo-aware V6 aggregation.
|
|
497
|
+
// Detects sub-apps with V6 layout active under apps/* and routes app-
|
|
498
|
+
// tagged entries away from the root (which would otherwise duplicate
|
|
499
|
+
// features that the sub-app pipeline already owns). Cross-cutting
|
|
500
|
+
// entries (no app source) and ambiguous entries (multiple apps) stay
|
|
501
|
+
// at root. Root index lists everything with cross-app paths.
|
|
502
|
+
//
|
|
503
|
+
// @cap-decision(F-096) Read-only on sub-apps: root pipeline NEVER writes
|
|
504
|
+
// to apps/<app>/.cap/memory/. Sub-app pipeline owns its own features/.
|
|
505
|
+
// Root just uses sub-app filenames for the index. Avoids race conditions
|
|
506
|
+
// between root + sub-app pipeline runs.
|
|
507
|
+
//
|
|
508
|
+
// @cap-decision(F-096) Auto-detected, no flag: monorepo layout is detected
|
|
509
|
+
// by presence of apps/<name>/.cap/memory/decisions.md with the (V6 Index)
|
|
510
|
+
// marker. No user-facing flag — symmetrical to F-093 (config-only) and
|
|
511
|
+
// keeps the surface area small.
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Detect monorepo layout: returns array of sub-app names that have V6 layout active.
|
|
515
|
+
* Empty array means single-app project (or no V6 sub-apps yet) — fall back to F-093 default.
|
|
516
|
+
* @param {string} projectRoot
|
|
517
|
+
* @returns {string[]} sub-app names (e.g. ['hub', 'booking'])
|
|
518
|
+
*/
|
|
519
|
+
function _isMonorepoLayout(projectRoot) {
|
|
520
|
+
if (!projectRoot) return [];
|
|
521
|
+
const appsDir = path.join(projectRoot, 'apps');
|
|
522
|
+
if (!fs.existsSync(appsDir)) return [];
|
|
523
|
+
let entries;
|
|
524
|
+
try {
|
|
525
|
+
entries = fs.readdirSync(appsDir, { withFileTypes: true });
|
|
526
|
+
} catch (_e) {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
const v6Apps = [];
|
|
530
|
+
for (const entry of entries) {
|
|
531
|
+
if (!entry.isDirectory()) continue;
|
|
532
|
+
const subDecisions = path.join(appsDir, entry.name, '.cap', 'memory', 'decisions.md');
|
|
533
|
+
if (!fs.existsSync(subDecisions)) continue;
|
|
534
|
+
let raw;
|
|
535
|
+
try { raw = fs.readFileSync(subDecisions, 'utf8'); } catch (_e) { continue; }
|
|
536
|
+
if (raw.includes('(V6 Index)')) v6Apps.push(entry.name);
|
|
537
|
+
}
|
|
538
|
+
return v6Apps;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolve which sub-app a source-file belongs to. Returns null if file is
|
|
543
|
+
* not under apps/<v6Apps[i]>/. Path is normalized to forward-slashes; leading
|
|
544
|
+
* slashes are stripped before matching.
|
|
545
|
+
* @param {string|undefined} filePath
|
|
546
|
+
* @param {string[]} v6Apps - sub-apps with V6 active
|
|
547
|
+
* @returns {string|null}
|
|
548
|
+
*/
|
|
549
|
+
function _resolveAppForFile(filePath, v6Apps) {
|
|
550
|
+
if (!filePath || !v6Apps || v6Apps.length === 0) return null;
|
|
551
|
+
const normalized = String(filePath).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
552
|
+
const match = normalized.match(/^apps\/([^/]+)\//);
|
|
553
|
+
if (!match) return null;
|
|
554
|
+
return v6Apps.includes(match[1]) ? match[1] : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Look up an existing V6 feature-file in a sub-app by featureId prefix.
|
|
559
|
+
* Returns the filename (e.g. "F-HUB-USER-MESSAGES-in-app-direktnachrichten.md")
|
|
560
|
+
* or null if no matching file exists. Used by the root index to render
|
|
561
|
+
* cross-app links without inventing slugs.
|
|
562
|
+
* @param {string} projectRoot
|
|
563
|
+
* @param {string} app
|
|
564
|
+
* @param {string} featureId
|
|
565
|
+
* @returns {string|null}
|
|
566
|
+
*/
|
|
567
|
+
function _findSubAppFeatureFile(projectRoot, app, featureId) {
|
|
568
|
+
const featuresDir = path.join(projectRoot, 'apps', app, '.cap', 'memory', 'features');
|
|
569
|
+
if (!fs.existsSync(featuresDir)) return null;
|
|
570
|
+
let files;
|
|
571
|
+
try { files = fs.readdirSync(featuresDir); } catch (_e) { return null; }
|
|
572
|
+
// CAP convention: filename = `<FEATURE-ID>-<slug>.md` where featureId is UPPERCASE
|
|
573
|
+
// and slug starts with a lowercase letter or digit. So `F-HUB-CHAT` should match
|
|
574
|
+
// `F-HUB-CHAT-some-slug.md` but NOT `F-HUB-CHAT-VOICE-NOTES-other.md` — the trailing
|
|
575
|
+
// `V` (uppercase) signals that VOICE-NOTES is a continuation of the featureId, not slug.
|
|
576
|
+
const prefix = `${featureId}-`;
|
|
577
|
+
const match = files.find((f) => {
|
|
578
|
+
if (!f.startsWith(prefix) || !f.endsWith('.md')) return false;
|
|
579
|
+
const after = f.slice(prefix.length);
|
|
580
|
+
if (after.length === 0) return false;
|
|
581
|
+
// First char of slug must be lowercase letter or digit (or hyphen for edge cases).
|
|
582
|
+
const first = after.charAt(0);
|
|
583
|
+
return first === first.toLowerCase() && /[a-z0-9]/.test(first);
|
|
584
|
+
});
|
|
585
|
+
return match || null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Slugify a string for use in filenames. Mirrors F-077's _slugify behavior
|
|
590
|
+
* (lowercase, alpha-num + hyphens, trim).
|
|
591
|
+
* @param {string} s
|
|
592
|
+
* @returns {string}
|
|
593
|
+
*/
|
|
594
|
+
function _slugifyForV6(s) {
|
|
595
|
+
return String(s || '')
|
|
596
|
+
.toLowerCase()
|
|
597
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
598
|
+
.replace(/^-+|-+$/g, '')
|
|
599
|
+
.slice(0, 80);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Resolve a feature title from FEATURE-MAP given the F-NNN id. Falls back to the id itself.
|
|
604
|
+
* @param {string} featureId
|
|
605
|
+
* @param {Array<{id: string, title: string}>=} features
|
|
606
|
+
* @returns {string}
|
|
607
|
+
*/
|
|
608
|
+
function _featureTitleFor(featureId, features) {
|
|
609
|
+
if (!features) return featureId;
|
|
610
|
+
const f = features.find((x) => x.id === featureId);
|
|
611
|
+
return f && f.title ? f.title : featureId;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Group entries into V6 destinations using F-077's classifier.
|
|
616
|
+
* Returns a Map keyed by destination identifier ("feature:F-XXX" or "platform:topic")
|
|
617
|
+
* with values { destination, featureId?, topic?, decisions, pitfalls }.
|
|
618
|
+
* @param {Array} entries
|
|
619
|
+
* @param {Object} context - F-077 ClassifierContext
|
|
620
|
+
* @returns {Map<string, {destination: string, featureId?: string, topic?: string, decisions: Array, pitfalls: Array}>}
|
|
621
|
+
*/
|
|
622
|
+
function _groupEntriesByDestination(entries, context, classifyEntry) {
|
|
623
|
+
const groups = new Map();
|
|
624
|
+
for (const entry of entries) {
|
|
625
|
+
if (entry.category === 'hotspot' || entry.category === 'pattern') continue; // V6 only handles decision/pitfall per F-076 schema
|
|
626
|
+
const v5Entry = {
|
|
627
|
+
kind: entry.category,
|
|
628
|
+
title: entry.content,
|
|
629
|
+
content: entry.content,
|
|
630
|
+
relatedFiles: (entry.metadata && entry.metadata.relatedFiles) || [],
|
|
631
|
+
taggedFeatureId: entry.metadata && entry.metadata.features && entry.metadata.features.length > 0 ? entry.metadata.features[0] : null,
|
|
632
|
+
anchorId: '',
|
|
633
|
+
dateLabel: '',
|
|
634
|
+
};
|
|
635
|
+
const decision = classifyEntry(v5Entry, context);
|
|
636
|
+
let key, group;
|
|
637
|
+
if (decision.destination === 'feature' && decision.confidence >= 0.7) {
|
|
638
|
+
key = `feature:${decision.featureId}`;
|
|
639
|
+
if (!groups.has(key)) groups.set(key, { destination: 'feature', featureId: decision.featureId, topic: decision.topic, decisions: [], pitfalls: [] });
|
|
640
|
+
group = groups.get(key);
|
|
641
|
+
} else {
|
|
642
|
+
const topic = decision.topic || 'unassigned';
|
|
643
|
+
key = `platform:${topic}`;
|
|
644
|
+
if (!groups.has(key)) groups.set(key, { destination: 'platform', topic, decisions: [], pitfalls: [] });
|
|
645
|
+
group = groups.get(key);
|
|
646
|
+
}
|
|
647
|
+
if (entry.category === 'decision') group.decisions.push(entry);
|
|
648
|
+
else group.pitfalls.push(entry);
|
|
649
|
+
}
|
|
650
|
+
return groups;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Build the auto-block items array (decisions or pitfalls) for F-076 schema.
|
|
655
|
+
* @param {Array} entries
|
|
656
|
+
* @returns {Array<{text: string, location?: string}>}
|
|
657
|
+
*/
|
|
658
|
+
function _toAutoBlockItems(entries) {
|
|
659
|
+
return entries.map((e) => {
|
|
660
|
+
const item = { text: e.content };
|
|
661
|
+
const files = (e.metadata && e.metadata.relatedFiles) || [];
|
|
662
|
+
if (files.length > 0) item.location = files[0];
|
|
663
|
+
return item;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Write the top-level Index file (decisions.md or pitfalls.md) summarizing
|
|
669
|
+
* per-feature counts. Replaces the V5 monolith with a sparse pointer table.
|
|
670
|
+
* @param {string} category 'decision' | 'pitfall'
|
|
671
|
+
* @param {Map} groups
|
|
672
|
+
* @param {Object} context
|
|
673
|
+
*/
|
|
674
|
+
function _renderV6Index(category, groups, context, indexOptions = {}) {
|
|
675
|
+
const filename = CATEGORY_FILES[category];
|
|
676
|
+
const titleCat = category.charAt(0).toUpperCase() + category.slice(1) + 's';
|
|
677
|
+
const isAggregating = Array.isArray(indexOptions.aggregatedAppFeatures);
|
|
678
|
+
const lines = [
|
|
679
|
+
`# Project Memory: ${titleCat} (V6 Index)`,
|
|
680
|
+
'',
|
|
681
|
+
isAggregating
|
|
682
|
+
? `> **V6 layout active (monorepo aggregation).** Per-feature ${category}s live in \`.cap/memory/features/\` (cross-cutting) and \`apps/<app>/.cap/memory/features/\` (app-owned, see "Cross-App" rows). This file is an auto-generated index — see the linked feature file for the actual entries.`
|
|
683
|
+
: `> **V6 layout active.** Per-feature ${category}s live in \`.cap/memory/features/\` and \`.cap/memory/platform/\`. This file is an auto-generated index — see the linked feature file for the actual entries.`,
|
|
684
|
+
`> Last updated: ${new Date().toISOString().substring(0, 10)}`,
|
|
685
|
+
'',
|
|
686
|
+
'| Destination | Count | File |',
|
|
687
|
+
'|---|---|---|',
|
|
688
|
+
];
|
|
689
|
+
// Sort: features alphabetically, then platform topics
|
|
690
|
+
const featureGroups = [...groups.values()].filter((g) => g.destination === 'feature').sort((a, b) => String(a.featureId).localeCompare(String(b.featureId)));
|
|
691
|
+
const platformGroups = [...groups.values()].filter((g) => g.destination === 'platform').sort((a, b) => String(a.topic).localeCompare(String(b.topic)));
|
|
692
|
+
for (const g of featureGroups) {
|
|
693
|
+
const items = category === 'decision' ? g.decisions : g.pitfalls;
|
|
694
|
+
if (items.length === 0) continue;
|
|
695
|
+
const slug = _slugifyForV6(_featureTitleFor(g.featureId, context.features));
|
|
696
|
+
const file = `features/${g.featureId}-${slug}.md`;
|
|
697
|
+
lines.push(`| ${g.featureId} | ${items.length} | [${file}](${file}) |`);
|
|
698
|
+
}
|
|
699
|
+
for (const g of platformGroups) {
|
|
700
|
+
const items = category === 'decision' ? g.decisions : g.pitfalls;
|
|
701
|
+
if (items.length === 0) continue;
|
|
702
|
+
const file = `platform/${g.topic}.md`;
|
|
703
|
+
lines.push(`| platform/${g.topic} | ${items.length} | [${file}](${file}) |`);
|
|
704
|
+
}
|
|
705
|
+
// F-096: Cross-app aggregated features (sub-app owns the file, root just indexes)
|
|
706
|
+
if (isAggregating && indexOptions.aggregatedAppFeatures.length > 0) {
|
|
707
|
+
const agg = [...indexOptions.aggregatedAppFeatures].sort((a, b) => {
|
|
708
|
+
if (a.app !== b.app) return a.app.localeCompare(b.app);
|
|
709
|
+
return a.featureId.localeCompare(b.featureId);
|
|
710
|
+
});
|
|
711
|
+
lines.push('');
|
|
712
|
+
lines.push(`## Cross-App (sub-app owned)`);
|
|
713
|
+
lines.push('');
|
|
714
|
+
lines.push('| Feature | App | Count | File |');
|
|
715
|
+
lines.push('|---|---|---|---|');
|
|
716
|
+
for (const f of agg) {
|
|
717
|
+
const count = category === 'decision' ? f.decisionsCount : f.pitfallsCount;
|
|
718
|
+
if (count === 0) continue;
|
|
719
|
+
// Path resolves from .cap/memory/decisions.md → ../../apps/<app>/.cap/memory/features/<file>
|
|
720
|
+
// If sub-app pipeline hasn't created the file yet (fileName === null), point at the directory.
|
|
721
|
+
const file = f.fileName
|
|
722
|
+
? `../../apps/${f.app}/.cap/memory/features/${f.fileName}`
|
|
723
|
+
: `../../apps/${f.app}/.cap/memory/features/`;
|
|
724
|
+
const display = f.fileName || `${f.featureId} (pending sub-app pipeline)`;
|
|
725
|
+
lines.push(`| ${f.featureId} | ${f.app} | ${count} | [${display}](${file}) |`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return lines.join('\n') + '\n';
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Write a per-feature file using F-076 schema (auto-block + manual-block preservation).
|
|
733
|
+
* @param {string} filePath
|
|
734
|
+
* @param {string} title
|
|
735
|
+
* @param {Array} decisions
|
|
736
|
+
* @param {Array} pitfalls
|
|
737
|
+
*/
|
|
738
|
+
function _writeV6FeatureFile(filePath, title, decisions, pitfalls) {
|
|
739
|
+
const schema = require('./cap-memory-schema.cjs');
|
|
740
|
+
let parsed;
|
|
741
|
+
try {
|
|
742
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
743
|
+
parsed = schema.parseFeatureMemoryFile(existing);
|
|
744
|
+
} catch (_e) {
|
|
745
|
+
parsed = {
|
|
746
|
+
frontmatter: {},
|
|
747
|
+
autoBlock: { decisions: [], pitfalls: [] },
|
|
748
|
+
manualBlock: { raw: `# ${title}\n\n` },
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
parsed.autoBlock = {
|
|
752
|
+
decisions: _toAutoBlockItems(decisions),
|
|
753
|
+
pitfalls: _toAutoBlockItems(pitfalls),
|
|
754
|
+
};
|
|
755
|
+
const out = schema.serializeFeatureMemoryFile(parsed);
|
|
756
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
757
|
+
fs.writeFileSync(filePath, out, 'utf8');
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Archive existing V5 monolith files to .archive/ before first V6 write,
|
|
762
|
+
* mirroring F-077's backup convention. Idempotent on same date.
|
|
763
|
+
* @param {string} projectRoot
|
|
764
|
+
*/
|
|
765
|
+
function _archiveV5IfPresent(projectRoot) {
|
|
766
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
767
|
+
const archiveDir = path.join(memDir, '.archive');
|
|
768
|
+
const date = new Date().toISOString().substring(0, 10);
|
|
769
|
+
for (const filename of Object.values(CATEGORY_FILES)) {
|
|
770
|
+
const src = path.join(memDir, filename);
|
|
771
|
+
if (!fs.existsSync(src)) continue;
|
|
772
|
+
// Only archive if file looks like a V5 monolith — V6 index files have a special marker.
|
|
773
|
+
let raw;
|
|
774
|
+
try { raw = fs.readFileSync(src, 'utf8'); } catch (_e) { continue; }
|
|
775
|
+
if (raw.includes('(V6 Index)')) continue; // already a V6 index, skip
|
|
776
|
+
const base = filename.replace(/\.md$/, '');
|
|
777
|
+
const dest = path.join(archiveDir, `${base}-pre-v6-${date}.md`);
|
|
778
|
+
if (fs.existsSync(dest)) continue; // idempotent on same date
|
|
779
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
780
|
+
fs.copyFileSync(src, dest);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* V6 layout writer — entry point dispatched from writeMemoryDirectory when
|
|
786
|
+
* { memory: { layout: 'v6' } } is set in .cap/config.json.
|
|
787
|
+
* @param {string} projectRoot
|
|
788
|
+
* @param {Array} entries
|
|
789
|
+
* @param {Object} options
|
|
790
|
+
* @returns {{files: Object<string, string>, written: number}}
|
|
791
|
+
*/
|
|
792
|
+
function _writeMemoryV6(projectRoot, entries, options = {}) {
|
|
793
|
+
// Lazy-load the F-077 classifier to avoid coupling the V5 path.
|
|
794
|
+
const migrate = require('./cap-memory-migrate.cjs');
|
|
795
|
+
const context = migrate.buildClassifierContext(projectRoot);
|
|
796
|
+
|
|
797
|
+
const groups = _groupEntriesByDestination(entries, context, migrate.classifyEntry);
|
|
798
|
+
|
|
799
|
+
// F-096: detect monorepo aggregation mode. Returns sub-apps with V6 active.
|
|
800
|
+
// options.aggregate === false explicitly opts out (test-friendly + escape hatch).
|
|
801
|
+
const v6Apps = options.aggregate === false ? [] : _isMonorepoLayout(projectRoot);
|
|
802
|
+
const isAggregating = v6Apps.length > 0;
|
|
803
|
+
|
|
804
|
+
// F-096: split groups into "owned by sub-app" (skip writing locally, just index)
|
|
805
|
+
// vs "stays at root" (write locally as before).
|
|
806
|
+
// A feature-group is owned by a sub-app when ALL its source-files resolve to that
|
|
807
|
+
// single app. Multi-app or no-app entries stay at root (cross-cutting / ambiguous).
|
|
808
|
+
const aggregatedAppFeatures = []; // [{ app, featureId, count, decisionsCount, pitfallsCount, fileName }]
|
|
809
|
+
const localGroups = new Map();
|
|
810
|
+
for (const [key, g] of groups) {
|
|
811
|
+
if (isAggregating && g.destination === 'feature') {
|
|
812
|
+
const items = [...g.decisions, ...g.pitfalls];
|
|
813
|
+
const apps = new Set(
|
|
814
|
+
items
|
|
815
|
+
.map((e) => {
|
|
816
|
+
const f = (e.metadata && e.metadata.relatedFiles && e.metadata.relatedFiles[0]) || e.file;
|
|
817
|
+
return _resolveAppForFile(f, v6Apps);
|
|
818
|
+
})
|
|
819
|
+
.filter(Boolean),
|
|
820
|
+
);
|
|
821
|
+
if (apps.size === 1) {
|
|
822
|
+
// Single sub-app owns this feature → don't write at root, just track for index.
|
|
823
|
+
const app = [...apps][0];
|
|
824
|
+
const fileName = _findSubAppFeatureFile(projectRoot, app, g.featureId);
|
|
825
|
+
aggregatedAppFeatures.push({
|
|
826
|
+
app,
|
|
827
|
+
featureId: g.featureId,
|
|
828
|
+
decisionsCount: g.decisions.length,
|
|
829
|
+
pitfallsCount: g.pitfalls.length,
|
|
830
|
+
fileName, // may be null if sub-app pipeline hasn't created it yet
|
|
831
|
+
});
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// Multi-app or no-app → keep at root.
|
|
835
|
+
}
|
|
836
|
+
localGroups.set(key, g);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
840
|
+
if (!options.dryRun) {
|
|
841
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
842
|
+
_archiveV5IfPresent(projectRoot);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const files = {};
|
|
846
|
+
let written = 0;
|
|
847
|
+
|
|
848
|
+
// Per-feature + per-platform writes (only localGroups in aggregation mode)
|
|
849
|
+
for (const g of localGroups.values()) {
|
|
850
|
+
let filePath, title;
|
|
851
|
+
if (g.destination === 'feature') {
|
|
852
|
+
const slug = _slugifyForV6(_featureTitleFor(g.featureId, context.features));
|
|
853
|
+
filePath = path.join(memDir, 'features', `${g.featureId}-${slug}.md`);
|
|
854
|
+
title = `${g.featureId}: ${_featureTitleFor(g.featureId, context.features)}`;
|
|
855
|
+
} else {
|
|
856
|
+
filePath = path.join(memDir, 'platform', `${g.topic}.md`);
|
|
857
|
+
title = `Platform: ${g.topic}`;
|
|
858
|
+
}
|
|
859
|
+
if (!options.dryRun) {
|
|
860
|
+
_writeV6FeatureFile(filePath, title, g.decisions, g.pitfalls);
|
|
861
|
+
written++;
|
|
862
|
+
}
|
|
863
|
+
// Snapshot for return value
|
|
864
|
+
const relKey = path.relative(memDir, filePath);
|
|
865
|
+
files[relKey] = `${title}\n decisions:${g.decisions.length}\n pitfalls:${g.pitfalls.length}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Top-level Index files (include aggregated app features in F-096 mode)
|
|
869
|
+
for (const cat of ['decision', 'pitfall']) {
|
|
870
|
+
const indexContent = _renderV6Index(cat, localGroups, context, {
|
|
871
|
+
aggregatedAppFeatures: isAggregating ? aggregatedAppFeatures : null,
|
|
872
|
+
});
|
|
873
|
+
const filename = CATEGORY_FILES[cat];
|
|
874
|
+
files[filename] = indexContent;
|
|
875
|
+
if (!options.dryRun) {
|
|
876
|
+
fs.writeFileSync(path.join(memDir, filename), indexContent, 'utf8');
|
|
877
|
+
written++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Skip patterns/hotspots in V6 — those remain V5-monolith for now (out-of-scope per F-076 schema).
|
|
882
|
+
// Generate empty stubs only if they don't exist, to preserve legacy callers.
|
|
883
|
+
|
|
884
|
+
return { files, written };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// @cap-feature(feature:F-095, primary:true) Memory Layout-Switch Activation CLI — leichtgewichtige Aktivierung von V6
|
|
888
|
+
// ohne session-reprocess. Liest existing V5 entries via readMemoryFile, persistiert config.json, ruft writeMemoryDirectory
|
|
889
|
+
// einmal mit V6-dispatch. Workflow-Lücke aus F-093: Stop-Hook returnt früh ohne neue Sessions, V6-config greift nicht
|
|
890
|
+
// beim ersten Toggle. /cap:memory init wäre heavy (alle Sessions reprocess); switchLayout ist <1s auf Hub-Größe.
|
|
891
|
+
// @cap-decision(F-095) write-then-rollback statt config-first: writeMemoryDirectory zuerst (try/catch), config.json
|
|
892
|
+
// wird erst NACH success geschrieben. Bei error bleiben V5-Files + alte config unverändert. Atomicity via Schreib-Order,
|
|
893
|
+
// nicht via Locks.
|
|
894
|
+
/**
|
|
895
|
+
* Switch the memory layout for a project (V5 → V6).
|
|
896
|
+
* Reads existing V5 entries, persists config.json with the new layout flag,
|
|
897
|
+
* and triggers writeMemoryDirectory once so the V6 dispatch produces the
|
|
898
|
+
* per-feature/platform files and Index.
|
|
899
|
+
*
|
|
900
|
+
* Idempotent for V6→V6: detects the `(V6 Index)` marker and short-circuits.
|
|
901
|
+
*
|
|
902
|
+
* @param {string} projectRoot
|
|
903
|
+
* @param {string} target - currently only 'v6' supported
|
|
904
|
+
* @returns {{ status: 'switched'|'noop', target: string, sourceEntries: number, written: number, configPath: string, archives: string[] }}
|
|
905
|
+
*/
|
|
906
|
+
function switchLayout(projectRoot, target) {
|
|
907
|
+
if (target !== 'v6') {
|
|
908
|
+
throw new Error(`switchLayout: unsupported target "${target}" (only "v6" supported in F-095)`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const memDir = path.join(projectRoot, MEMORY_DIR);
|
|
912
|
+
const configPath = path.join(projectRoot, '.cap', 'config.json');
|
|
913
|
+
|
|
914
|
+
// AC-3: idempotency check — if top-level decisions.md already has the V6 marker, no-op.
|
|
915
|
+
const decisionsFile = path.join(memDir, CATEGORY_FILES.decision);
|
|
916
|
+
if (fs.existsSync(decisionsFile)) {
|
|
917
|
+
const raw = fs.readFileSync(decisionsFile, 'utf8');
|
|
918
|
+
if (raw.includes('(V6 Index)')) {
|
|
919
|
+
return { status: 'noop', target, sourceEntries: 0, written: 0, configPath, archives: [] };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Read existing V5 entries (decisions + pitfalls). patterns/hotspots stay V5-monolith per F-093 schema.
|
|
924
|
+
const pitfallsFile = path.join(memDir, CATEGORY_FILES.pitfall);
|
|
925
|
+
const decEntries = fs.existsSync(decisionsFile)
|
|
926
|
+
? readMemoryFile(decisionsFile).entries.map(e => ({ ...e, category: 'decision' }))
|
|
927
|
+
: [];
|
|
928
|
+
const pitEntries = fs.existsSync(pitfallsFile)
|
|
929
|
+
? readMemoryFile(pitfallsFile).entries.map(e => ({ ...e, category: 'pitfall' }))
|
|
930
|
+
: [];
|
|
931
|
+
const allEntries = [...decEntries, ...pitEntries];
|
|
932
|
+
|
|
933
|
+
// AC-2: writeMemoryDirectory zuerst (force layout via options); config.json schreiben wir erst nach success.
|
|
934
|
+
// Force layout via options.layout — bypasses config.json read so a missing/invalid config doesn't block the switch.
|
|
935
|
+
const result = writeMemoryDirectory(projectRoot, allEntries, { layout: target });
|
|
936
|
+
|
|
937
|
+
// Persist config.json (merge with existing if present).
|
|
938
|
+
const capDir = path.join(projectRoot, '.cap');
|
|
939
|
+
if (!fs.existsSync(capDir)) fs.mkdirSync(capDir, { recursive: true });
|
|
940
|
+
let config = {};
|
|
941
|
+
if (fs.existsSync(configPath)) {
|
|
942
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')) || {}; } catch (_e) { config = {}; }
|
|
943
|
+
}
|
|
944
|
+
config.memory = { ...(config.memory || {}), layout: target };
|
|
945
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
946
|
+
|
|
947
|
+
// Collect archive list for reporting (AC-4).
|
|
948
|
+
const archiveDir = path.join(memDir, '.archive');
|
|
949
|
+
const archives = fs.existsSync(archiveDir)
|
|
950
|
+
? fs.readdirSync(archiveDir).filter(f => f.includes('pre-v6'))
|
|
951
|
+
: [];
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
status: 'switched',
|
|
955
|
+
target,
|
|
956
|
+
sourceEntries: allEntries.length,
|
|
957
|
+
written: result.written,
|
|
958
|
+
configPath,
|
|
959
|
+
archives,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
module.exports = {
|
|
964
|
+
generateAnchorId,
|
|
965
|
+
generateCategoryMarkdown,
|
|
966
|
+
parseExistingAnchors,
|
|
967
|
+
writeMemoryDirectory,
|
|
968
|
+
readMemoryDirectory,
|
|
969
|
+
readMemoryFile,
|
|
970
|
+
getCrossReference,
|
|
971
|
+
// F-090: confidence filter exposed for tests + downstream tools that want the same gating.
|
|
972
|
+
_filterEntriesForOutput,
|
|
973
|
+
// F-093: V6 layout helpers exposed for testing.
|
|
974
|
+
_isV6LayoutEnabled,
|
|
975
|
+
_writeMemoryV6,
|
|
976
|
+
_groupEntriesByDestination,
|
|
977
|
+
_renderV6Index,
|
|
978
|
+
_archiveV5IfPresent,
|
|
979
|
+
// F-095: Layout-Switch Activation CLI.
|
|
980
|
+
switchLayout,
|
|
981
|
+
// F-096: Cross-app aggregation helpers exposed for testing.
|
|
982
|
+
_isMonorepoLayout,
|
|
983
|
+
_resolveAppForFile,
|
|
984
|
+
_findSubAppFeatureFile,
|
|
985
|
+
MEMORY_DIR,
|
|
986
|
+
CATEGORY_FILES,
|
|
987
|
+
};
|