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,764 @@
|
|
|
1
|
+
// @cap-feature(feature:F-080, primary:true) Bridge to Claude-native Memory —
|
|
2
|
+
// read-only consumer of ~/.claude/projects/<slug>/memory/ MEMORY.md + sibling files.
|
|
3
|
+
//
|
|
4
|
+
// @cap-context This module owns the read-only contract between Claude Code's auto-memory
|
|
5
|
+
// (~/.claude/projects/<slug>/memory/) and the CAP runtime surface (/cap:start, /cap:status).
|
|
6
|
+
// It NEVER writes into the Claude-native directory — the bridge is strictly a one-way
|
|
7
|
+
// pull. The local cache (.cap/memory/.claude-native-index.json) is the only thing this
|
|
8
|
+
// module writes, and it's a derived artifact under the project's own .cap/ tree.
|
|
9
|
+
//
|
|
10
|
+
// @cap-context AC-4 wires the surface into /cap:start + /cap:status as a runtime-only
|
|
11
|
+
// echo. The bridge does NOT persist its data into per-feature memory files — see
|
|
12
|
+
// @cap-decision(F-080/spec-gap) below for the runtime-only rationale.
|
|
13
|
+
//
|
|
14
|
+
// @cap-decision(F-080/AC-1) Read-only contract is sacred: NEVER write to
|
|
15
|
+
// ~/.claude/projects/<slug>/memory/. The cache lives under .cap/memory/ and is the only
|
|
16
|
+
// write target. Tests assert the source dir's mtime + content stay byte-identical across
|
|
17
|
+
// bridge invocations (cap-memory-bridge-adversarial.test.cjs).
|
|
18
|
+
//
|
|
19
|
+
// @cap-decision(F-080/AC-3) Missing or unreadable Claude-native dir → graceful skip
|
|
20
|
+
// (silent: no stdout, no stderr, no throw). The user may not have set up auto-memory
|
|
21
|
+
// yet, or may be running in an environment without ~/.claude/projects/. The bridge must
|
|
22
|
+
// degrade silently rather than fail the surrounding command.
|
|
23
|
+
//
|
|
24
|
+
// @cap-decision(F-080/AC-5) Surface priority order is fixed:
|
|
25
|
+
// 1. Entries whose title/file mentions the activeFeature ID (e.g. F-080)
|
|
26
|
+
// 2. Entries that match any related_features from the feature's per-feature memory file
|
|
27
|
+
// 3. Last 2 globally-recent entries (by file mtime desc) as fallback context
|
|
28
|
+
// Hard cap: 5 bullets total. If the priority sort yields more, truncate.
|
|
29
|
+
//
|
|
30
|
+
// @cap-decision(F-080/spec-gap) AC-4 wording "surface" is interpreted as RUNTIME-ONLY
|
|
31
|
+
// stdout (not persistence into per-feature files). Rationale: lower blast radius — the
|
|
32
|
+
// bridge stays purely additive, no schema changes to per-feature files, no auto-block
|
|
33
|
+
// pollution. If a future feature needs persistence, a new auto-block name (e.g.
|
|
34
|
+
// `claude_native_recall`) under F-079's `<!-- @auto-block <name> -->` convention can be
|
|
35
|
+
// added without touching this module.
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const os = require('node:os');
|
|
42
|
+
|
|
43
|
+
const schema = require('./cap-memory-schema.cjs');
|
|
44
|
+
|
|
45
|
+
// -------- Constants --------
|
|
46
|
+
|
|
47
|
+
// @cap-decision(F-080/D1) Cache file lives under the project's own .cap/memory/ tree
|
|
48
|
+
// (NOT inside .cap/memory/features/). Naming convention `.claude-native-index.json` —
|
|
49
|
+
// the leading dot signals "derived/transient" and aligns with `.cap/memory/.last-run`.
|
|
50
|
+
const CACHE_REL_PATH = path.join('.cap', 'memory', '.claude-native-index.json');
|
|
51
|
+
|
|
52
|
+
// @cap-decision(F-080/D2) Cache schema version starts at 1. Bumping is a hard-invalidate
|
|
53
|
+
// signal — if a future iteration changes the entry shape, increment this and the loader
|
|
54
|
+
// will refuse to honor the old cache (forces a re-parse).
|
|
55
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
56
|
+
|
|
57
|
+
// @cap-decision(F-080/D3) Hard cap on surface bullets. Spec AC-5 says "max 5 per run".
|
|
58
|
+
// Enforced as a single MAX_BULLETS constant so a future tweak is a one-line change.
|
|
59
|
+
const MAX_BULLETS = 5;
|
|
60
|
+
|
|
61
|
+
// @cap-decision(F-080/D4) Slug-character regex for the project-slug derivation. Claude
|
|
62
|
+
// Code's auto-memory directory uses the absolute path with `/` → `-`. We accept the
|
|
63
|
+
// resulting alphabet (alnum + `-` + `.` + `_`). Defense-in-depth: reject anything else
|
|
64
|
+
// in _validateSlug below.
|
|
65
|
+
const SLUG_CHAR_RE = /^[A-Za-z0-9._-]+$/;
|
|
66
|
+
|
|
67
|
+
// @cap-decision(F-080/D5) Reserved slug tokens that would survive the regex but still
|
|
68
|
+
// pose a path-traversal risk (e.g. `..` matches the regex above). Hard-reject these.
|
|
69
|
+
const RESERVED_SLUG_TOKENS = new Set(['..', '.', '__proto__', 'constructor', 'prototype']);
|
|
70
|
+
|
|
71
|
+
// -------- Defensive helpers --------
|
|
72
|
+
|
|
73
|
+
// @cap-decision(F-080/D6) ANSI/control-byte sanitization for any user-supplied string
|
|
74
|
+
// that could land in stdout (entry titles, hooks). Mirrors cap-memory-platform.cjs and
|
|
75
|
+
// cap-snapshot-linkage.cjs `_safeForError` — kept local so a refactor in one module
|
|
76
|
+
// can't silently weaken the defense in another.
|
|
77
|
+
function _safeForOutput(value) {
|
|
78
|
+
if (typeof value !== 'string') return String(value);
|
|
79
|
+
// Replace any byte outside printable ASCII (excluding DEL) with `?`. Strip to 200 chars
|
|
80
|
+
// to keep surface lines bounded — entry titles longer than that get visually truncated.
|
|
81
|
+
return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 200);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// @cap-risk(reason:path-traversal-via-cwd) The project-slug is derived from the absolute
|
|
85
|
+
// cwd path. If cwd contains `..` or symlinks, we still produce a safe slug because we
|
|
86
|
+
// transform `/` → `-` (no `..`-segment can sneak through), but we double-check with
|
|
87
|
+
// _validateSlug below. Defense-in-depth pattern from F-078/D4 + F-079 _validateSnapshotName.
|
|
88
|
+
function _validateSlug(slug) {
|
|
89
|
+
if (typeof slug !== 'string' || slug.length === 0) {
|
|
90
|
+
throw new TypeError(`project-slug must be a non-empty string (got ${typeof slug})`);
|
|
91
|
+
}
|
|
92
|
+
if (slug.includes('/') || slug.includes('\\') || slug.includes('\0')) {
|
|
93
|
+
throw new TypeError(`project-slug must not contain path separators or NUL (got "${_safeForOutput(slug)}")`);
|
|
94
|
+
}
|
|
95
|
+
if (RESERVED_SLUG_TOKENS.has(slug)) {
|
|
96
|
+
throw new TypeError(`project-slug is a reserved token (got "${_safeForOutput(slug)}")`);
|
|
97
|
+
}
|
|
98
|
+
if (!SLUG_CHAR_RE.test(slug)) {
|
|
99
|
+
throw new TypeError(`project-slug must match ${SLUG_CHAR_RE} (got "${_safeForOutput(slug)}")`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -------- Slug derivation --------
|
|
104
|
+
|
|
105
|
+
// @cap-todo(ac:F-080/AC-1) getProjectSlug derives the Claude-native auto-memory slug from
|
|
106
|
+
// an absolute project path. Claude Code's convention: replace `/` with `-`, keep dots
|
|
107
|
+
// and other path-safe chars verbatim.
|
|
108
|
+
/**
|
|
109
|
+
* Derive the Claude-native auto-memory project slug from an absolute project path.
|
|
110
|
+
* Convention (Claude Code): the absolute project path with `/` substituted by `-`, e.g.
|
|
111
|
+
* `/Users/foo/bar` → `-Users-foo-bar`.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
114
|
+
* @returns {string} slug (validated)
|
|
115
|
+
*/
|
|
116
|
+
function getProjectSlug(projectRoot) {
|
|
117
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
118
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
119
|
+
}
|
|
120
|
+
// Normalize away any trailing slash and resolve `..` segments before slug-ifying so
|
|
121
|
+
// cwd `/Users/foo/bar/` and `/Users/foo/bar/baz/..` both produce the same slug.
|
|
122
|
+
const normalized = path.resolve(projectRoot);
|
|
123
|
+
// Reject path-traversal sigils in the resolved path defensively (path.resolve already
|
|
124
|
+
// eliminates `..` but the input may contain a NUL byte that survives).
|
|
125
|
+
if (normalized.includes('\0')) {
|
|
126
|
+
throw new TypeError(`projectRoot contains NUL byte (got "${_safeForOutput(projectRoot)}")`);
|
|
127
|
+
}
|
|
128
|
+
// Replace BOTH POSIX `/` and Windows `\` with `-` for cross-platform safety. The
|
|
129
|
+
// Claude-native convention is observed on POSIX as `/` → `-`; on Windows the parallel
|
|
130
|
+
// is `\` → `-`. Both substitutions are idempotent.
|
|
131
|
+
const slug = normalized.replace(/[/\\]/g, '-');
|
|
132
|
+
_validateSlug(slug);
|
|
133
|
+
return slug;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// @cap-todo(ac:F-080/AC-1) getClaudeNativeDir builds the absolute path to the Claude-native
|
|
137
|
+
// auto-memory directory: ~/.claude/projects/<slug>/memory/.
|
|
138
|
+
// @cap-risk(reason:claude-native-layout-dependency) Bridge depends on Claude Code's
|
|
139
|
+
// ~/.claude/projects/<slug>/memory/ layout convention. If Claude Code changes this (e.g. to
|
|
140
|
+
// XDG_CONFIG_HOME or a per-user data directory), the bridge will silent-skip but produce no
|
|
141
|
+
// surface data. The silent-skip contract masks the failure, so a layout change would degrade
|
|
142
|
+
// without a visible error. Track via test fixture against a known-stable layout assumption;
|
|
143
|
+
// when Claude Code's filesystem conventions change, surface the new path here and bump the
|
|
144
|
+
// cache schema version to invalidate stale caches.
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} projectRoot
|
|
147
|
+
* @returns {string} absolute path to ~/.claude/projects/<slug>/memory/
|
|
148
|
+
*/
|
|
149
|
+
function getClaudeNativeDir(projectRoot) {
|
|
150
|
+
const slug = getProjectSlug(projectRoot);
|
|
151
|
+
return path.join(os.homedir(), '.claude', 'projects', slug, 'memory');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// @cap-todo(ac:F-080/AC-1) getClaudeNativeMemoryMdPath builds the path to the index file
|
|
155
|
+
// (MEMORY.md) under the Claude-native dir.
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} projectRoot
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
function getClaudeNativeMemoryMdPath(projectRoot) {
|
|
161
|
+
return path.join(getClaudeNativeDir(projectRoot), 'MEMORY.md');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// @cap-todo(ac:F-080/AC-2) getCachePath builds the path to the local cache file under
|
|
165
|
+
// .cap/memory/.claude-native-index.json.
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} projectRoot
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
function getCachePath(projectRoot) {
|
|
171
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
172
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
173
|
+
}
|
|
174
|
+
return path.join(projectRoot, CACHE_REL_PATH);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// -------- MEMORY.md parser --------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {Object} ClaudeNativeEntry
|
|
181
|
+
* @property {string} title - human title from the bullet
|
|
182
|
+
* @property {string} file - sibling filename (relative to memory dir)
|
|
183
|
+
* @property {string} hook - one-line hook text after the em-dash
|
|
184
|
+
* @property {string|null} type - 'user'|'feedback'|'project'|'reference'|null (from sibling frontmatter)
|
|
185
|
+
* @property {string|null} fileMtime - ISO mtime of the sibling file (null if missing)
|
|
186
|
+
* @property {string|null} description - sibling's frontmatter description if present
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
// @cap-decision(F-080/D7) MEMORY.md grammar: each line is `- [Title](file.md) — hook text`.
|
|
190
|
+
// Tolerate both em-dash (—), en-dash (–) and regular hyphen (-) as separator (mirrors the
|
|
191
|
+
// F-082 em-dash lesson). A line that doesn't match the bullet shape is silently skipped —
|
|
192
|
+
// MEMORY.md may have prose interspersed (header notes, comments).
|
|
193
|
+
// @cap-decision(F-080/followup) F-080-FIX-B: MEMORY_MD_LINE_RE requires surrounding spaces
|
|
194
|
+
// for hyphen. Previously the separator class `[—–-]` matched a hyphen with no required
|
|
195
|
+
// surrounding whitespace, which could ambiguously split titles that contain a hyphen
|
|
196
|
+
// (e.g. `[Foo-Bar](file.md) Description`). Em-dash (—) and en-dash (–) are multi-byte and
|
|
197
|
+
// unambiguous, but the hyphen branch now requires `\s+-\s+` for consistency. The em/en-dash
|
|
198
|
+
// branch retains its existing `\s*[—–]\s*` tolerance to avoid breaking existing fixtures.
|
|
199
|
+
const MEMORY_MD_LINE_RE = /^-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*[—–]\s*|\s+-\s+)(.+?)\s*$/;
|
|
200
|
+
|
|
201
|
+
// @cap-todo(ac:F-080/AC-1) parseMemoryMd parses the index file into structured entries.
|
|
202
|
+
// Frontmatter on sibling files is read via parseSiblingFrontmatter.
|
|
203
|
+
//
|
|
204
|
+
// @cap-decision(F-080/iter0/D8) Sibling-file reads are best-effort: a missing or
|
|
205
|
+
// unreadable sibling is dropped from the parse with no error (the index entry survives
|
|
206
|
+
// but `type` and `description` will be null). This keeps a partially-broken auto-memory
|
|
207
|
+
// dir surface-able rather than blocking the bridge entirely.
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} memoryDir - absolute path to ~/.claude/projects/<slug>/memory/
|
|
210
|
+
* @returns {ClaudeNativeEntry[]}
|
|
211
|
+
*/
|
|
212
|
+
function parseMemoryMd(memoryDir) {
|
|
213
|
+
if (typeof memoryDir !== 'string' || memoryDir.length === 0) {
|
|
214
|
+
throw new TypeError('memoryDir must be a non-empty string');
|
|
215
|
+
}
|
|
216
|
+
const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
|
|
217
|
+
if (!fs.existsSync(memoryMdPath)) return [];
|
|
218
|
+
let raw;
|
|
219
|
+
try {
|
|
220
|
+
raw = fs.readFileSync(memoryMdPath, 'utf8');
|
|
221
|
+
} catch (_e) {
|
|
222
|
+
// Unreadable index → treat as empty (graceful).
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
/** @type {ClaudeNativeEntry[]} */
|
|
226
|
+
const entries = [];
|
|
227
|
+
const seenFiles = new Set();
|
|
228
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
229
|
+
const line = rawLine.replace(/^\s+|\s+$/g, '');
|
|
230
|
+
if (line.length === 0) continue;
|
|
231
|
+
const m = line.match(MEMORY_MD_LINE_RE);
|
|
232
|
+
if (!m) continue;
|
|
233
|
+
const title = m[1].trim();
|
|
234
|
+
const fileRel = m[2].trim();
|
|
235
|
+
const hook = m[3].trim();
|
|
236
|
+
// Defensive: reject sibling references that try to escape the memory dir. The expected
|
|
237
|
+
// shape is a bare filename (no slash, no leading dot beyond `.md`).
|
|
238
|
+
if (fileRel.includes('/') || fileRel.includes('\\') || fileRel.includes('\0') || fileRel.includes('..')) continue;
|
|
239
|
+
if (seenFiles.has(fileRel)) continue; // dedup by file
|
|
240
|
+
seenFiles.add(fileRel);
|
|
241
|
+
const sibling = parseSiblingFrontmatter(memoryDir, fileRel);
|
|
242
|
+
// @cap-decision(F-080/followup) F-080-FIX-A: _safeForOutput at parse-time, not surface-time.
|
|
243
|
+
// Previously only entry titles were sanitized (at the surface step). Hook + description
|
|
244
|
+
// strings flowed through unsanitized into entries[]. Today only formatSurface consumes
|
|
245
|
+
// entries (and it sanitizes titles), so this isn't an active vulnerability — but a future
|
|
246
|
+
// caller pulling raw entries (e.g. a verbose mode, debug command, LLM context block) would
|
|
247
|
+
// render ANSI bytes verbatim. Sanitize at the storage assembly step so EVERY consumer of
|
|
248
|
+
// entries[] gets pre-sanitized data.
|
|
249
|
+
entries.push({
|
|
250
|
+
title: _safeForOutput(title),
|
|
251
|
+
file: fileRel,
|
|
252
|
+
hook: _safeForOutput(hook),
|
|
253
|
+
type: sibling.type,
|
|
254
|
+
fileMtime: sibling.mtime,
|
|
255
|
+
description: sibling.description == null ? null : _safeForOutput(sibling.description),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} memoryDir
|
|
263
|
+
* @param {string} fileRel
|
|
264
|
+
* @returns {{type:string|null, description:string|null, mtime:string|null}}
|
|
265
|
+
*/
|
|
266
|
+
function parseSiblingFrontmatter(memoryDir, fileRel) {
|
|
267
|
+
const fp = path.join(memoryDir, fileRel);
|
|
268
|
+
/** @type {{type:string|null, description:string|null, mtime:string|null}} */
|
|
269
|
+
const empty = { type: null, description: null, mtime: null };
|
|
270
|
+
if (!fs.existsSync(fp)) return empty;
|
|
271
|
+
let stat;
|
|
272
|
+
let content;
|
|
273
|
+
try {
|
|
274
|
+
stat = fs.statSync(fp);
|
|
275
|
+
content = fs.readFileSync(fp, 'utf8');
|
|
276
|
+
} catch (_e) {
|
|
277
|
+
return empty;
|
|
278
|
+
}
|
|
279
|
+
const mtime = stat.mtime.toISOString();
|
|
280
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
281
|
+
if (!fmMatch) return { type: null, description: null, mtime };
|
|
282
|
+
const fmBody = fmMatch[1];
|
|
283
|
+
let type = null;
|
|
284
|
+
let description = null;
|
|
285
|
+
// @cap-risk(reason:proto-pollution-via-frontmatter) Sibling frontmatter is YAML-like.
|
|
286
|
+
// Skip reserved tokens explicitly (defense-in-depth — we never use the parsed values
|
|
287
|
+
// as object keys, only assign to fixed fields, but the tradition is established).
|
|
288
|
+
const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
289
|
+
for (const line of fmBody.split(/\r?\n/)) {
|
|
290
|
+
const m = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
|
291
|
+
if (!m) continue;
|
|
292
|
+
const key = m[1];
|
|
293
|
+
if (RESERVED.has(key)) continue;
|
|
294
|
+
const val = (m[2] || '').replace(/^["']|["']$/g, '').trim();
|
|
295
|
+
if (key === 'type') type = val || null;
|
|
296
|
+
else if (key === 'description') description = val || null;
|
|
297
|
+
}
|
|
298
|
+
return { type, description, mtime };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// -------- Cache I/O --------
|
|
302
|
+
|
|
303
|
+
// @cap-todo(ac:F-080/AC-2) loadCachedIndex reads the local cache file and returns the
|
|
304
|
+
// parsed structure (or null on any failure — cache is best-effort).
|
|
305
|
+
/**
|
|
306
|
+
* @param {string} projectRoot
|
|
307
|
+
* @returns {{schemaVersion:number, sourceRoot:string, memoryMdMtime:string|null, entries:ClaudeNativeEntry[]}|null}
|
|
308
|
+
*/
|
|
309
|
+
function loadCachedIndex(projectRoot) {
|
|
310
|
+
const cachePath = getCachePath(projectRoot);
|
|
311
|
+
if (!fs.existsSync(cachePath)) return null;
|
|
312
|
+
let raw;
|
|
313
|
+
try {
|
|
314
|
+
raw = fs.readFileSync(cachePath, 'utf8');
|
|
315
|
+
} catch (_e) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
let parsed;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(raw);
|
|
321
|
+
} catch (_e) {
|
|
322
|
+
// @cap-decision(F-080/iter0/D9) Corrupt cache JSON → treat as missing cache (caller
|
|
323
|
+
// re-parses from source). The cache file is regenerated on the next refresh, so
|
|
324
|
+
// there's no persistent failure mode here. Loud-throw was rejected because corrupt
|
|
325
|
+
// cache shouldn't block surface output — re-parse is the cheap, safe path.
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
329
|
+
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
330
|
+
if (!Array.isArray(parsed.entries)) return null;
|
|
331
|
+
// Sanitize entries — accept only entries with the expected shape.
|
|
332
|
+
/** @type {ClaudeNativeEntry[]} */
|
|
333
|
+
const safeEntries = [];
|
|
334
|
+
const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
335
|
+
for (const e of parsed.entries) {
|
|
336
|
+
if (!e || typeof e !== 'object') continue;
|
|
337
|
+
if (typeof e.title !== 'string' || typeof e.file !== 'string') continue;
|
|
338
|
+
if (RESERVED.has(e.file)) continue;
|
|
339
|
+
if (e.file.includes('/') || e.file.includes('\\') || e.file.includes('..')) continue;
|
|
340
|
+
// @cap-decision(F-080/followup) F-080-FIX-A: _safeForOutput at parse-time, not surface-time.
|
|
341
|
+
// Cache is a derived artifact — but a malicious or corrupted cache could carry ANSI bytes
|
|
342
|
+
// if a previous version (or a third party) wrote them. Sanitize on load symmetrically with
|
|
343
|
+
// parseMemoryMd so entries[] are uniformly safe regardless of source path.
|
|
344
|
+
safeEntries.push({
|
|
345
|
+
title: _safeForOutput(e.title),
|
|
346
|
+
file: e.file,
|
|
347
|
+
hook: typeof e.hook === 'string' ? _safeForOutput(e.hook) : '',
|
|
348
|
+
type: typeof e.type === 'string' ? e.type : null,
|
|
349
|
+
fileMtime: typeof e.fileMtime === 'string' ? e.fileMtime : null,
|
|
350
|
+
description: typeof e.description === 'string' ? _safeForOutput(e.description) : null,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
schemaVersion: parsed.schemaVersion,
|
|
355
|
+
sourceRoot: typeof parsed.sourceRoot === 'string' ? parsed.sourceRoot : '',
|
|
356
|
+
memoryMdMtime: typeof parsed.memoryMdMtime === 'string' ? parsed.memoryMdMtime : null,
|
|
357
|
+
entries: safeEntries,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// @cap-todo(ac:F-080/AC-2) isCacheValid compares cached mtimes against current source
|
|
362
|
+
// mtimes. Returns true ONLY if the cache exists, the index file mtime matches, and
|
|
363
|
+
// no sibling file is newer than the cache.
|
|
364
|
+
//
|
|
365
|
+
// @cap-risk(reason:cache-toctou-acceptable) Between isCacheValid() and the actual
|
|
366
|
+
// refresh/load, a sibling file could change. Worst case: caller surfaces one-tick-stale
|
|
367
|
+
// data. Acceptable for a read-only display surface.
|
|
368
|
+
/**
|
|
369
|
+
* @param {string} projectRoot
|
|
370
|
+
* @returns {boolean}
|
|
371
|
+
*/
|
|
372
|
+
function isCacheValid(projectRoot) {
|
|
373
|
+
const cached = loadCachedIndex(projectRoot);
|
|
374
|
+
if (!cached) return false;
|
|
375
|
+
const memoryMdPath = getClaudeNativeMemoryMdPath(projectRoot);
|
|
376
|
+
if (!fs.existsSync(memoryMdPath)) return false;
|
|
377
|
+
let stat;
|
|
378
|
+
try {
|
|
379
|
+
stat = fs.statSync(memoryMdPath);
|
|
380
|
+
} catch (_e) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
const currentMtime = stat.mtime.toISOString();
|
|
384
|
+
if (cached.memoryMdMtime !== currentMtime) return false;
|
|
385
|
+
// Check sibling files: if ANY referenced sibling has a newer mtime than recorded in the
|
|
386
|
+
// cache, invalidate. This catches the case where MEMORY.md is unchanged but a sibling's
|
|
387
|
+
// frontmatter (e.g. type, description) was edited.
|
|
388
|
+
const memoryDir = getClaudeNativeDir(projectRoot);
|
|
389
|
+
for (const entry of cached.entries) {
|
|
390
|
+
const fp = path.join(memoryDir, entry.file);
|
|
391
|
+
if (!fs.existsSync(fp)) {
|
|
392
|
+
// Sibling went missing → invalidate so the re-parse drops it.
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
let sStat;
|
|
396
|
+
try {
|
|
397
|
+
sStat = fs.statSync(fp);
|
|
398
|
+
} catch (_e) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
const currMtime = sStat.mtime.toISOString();
|
|
402
|
+
if (entry.fileMtime !== null && currMtime !== entry.fileMtime) return false;
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// @cap-todo(ac:F-080/AC-2) refreshCache re-parses MEMORY.md + sibling files and writes
|
|
408
|
+
// a fresh `.claude-native-index.json`. Atomic write (tmp + rename) so a crash mid-write
|
|
409
|
+
// doesn't corrupt the cache.
|
|
410
|
+
//
|
|
411
|
+
// @cap-decision(F-080/D10) Atomic write goes through a local helper rather than importing
|
|
412
|
+
// `_atomicWriteFile` from cap-memory-migrate.cjs. Reasons:
|
|
413
|
+
// 1. Lower coupling — F-080 is a leaf module, depending on cap-memory-migrate would
|
|
414
|
+
// pull in a much larger surface (the migrator owns the V6 transformation pipeline).
|
|
415
|
+
// 2. Symmetric with the parent dir creation we need anyway (cache lives in
|
|
416
|
+
// `.cap/memory/` which may not exist on first use).
|
|
417
|
+
// 3. The pattern is small (3 lines: write tmp, rename, optionally chmod). Duplication
|
|
418
|
+
// is cheaper than the dep edge.
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} projectRoot
|
|
421
|
+
* @returns {{written:boolean, entries:ClaudeNativeEntry[], reason:string}}
|
|
422
|
+
*/
|
|
423
|
+
function refreshCache(projectRoot) {
|
|
424
|
+
const memoryDir = getClaudeNativeDir(projectRoot);
|
|
425
|
+
const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
|
|
426
|
+
if (!fs.existsSync(memoryMdPath)) {
|
|
427
|
+
return { written: false, entries: [], reason: 'source-missing' };
|
|
428
|
+
}
|
|
429
|
+
let memoryMdStat;
|
|
430
|
+
try {
|
|
431
|
+
memoryMdStat = fs.statSync(memoryMdPath);
|
|
432
|
+
} catch (_e) {
|
|
433
|
+
return { written: false, entries: [], reason: 'source-stat-failed' };
|
|
434
|
+
}
|
|
435
|
+
const entries = parseMemoryMd(memoryDir);
|
|
436
|
+
const cachePayload = {
|
|
437
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
438
|
+
sourceRoot: memoryDir,
|
|
439
|
+
memoryMdMtime: memoryMdStat.mtime.toISOString(),
|
|
440
|
+
entries,
|
|
441
|
+
};
|
|
442
|
+
const cachePath = getCachePath(projectRoot);
|
|
443
|
+
// Ensure parent dir exists.
|
|
444
|
+
try {
|
|
445
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
446
|
+
} catch (_e) {
|
|
447
|
+
return { written: false, entries, reason: 'parent-dir-create-failed' };
|
|
448
|
+
}
|
|
449
|
+
// Atomic write: tmp + rename (matches F-074/F-078 pattern).
|
|
450
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
|
|
451
|
+
try {
|
|
452
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cachePayload, null, 2) + '\n', 'utf8');
|
|
453
|
+
_renameWithRetry(tmpPath, cachePath);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
// Cleanup tmp file if it exists.
|
|
456
|
+
try { fs.unlinkSync(tmpPath); } catch (_e2) { /* ignore */ }
|
|
457
|
+
// @cap-decision(F-080/followup) F-080-FIX-C: rename retry on EBUSY/EPERM with backoff.
|
|
458
|
+
// When _renameWithRetry exhausts its retries on Windows EBUSY/EPERM, surface a single
|
|
459
|
+
// warning to stderr (NOT silent-skip — this is a write failure, deserves visibility).
|
|
460
|
+
// Other rename errors fall through to the same warning path. The hook caller still
|
|
461
|
+
// continues normally; the "best-effort, never block" contract is upheld via the
|
|
462
|
+
// non-throwing return value.
|
|
463
|
+
if (e && (e.code === 'EBUSY' || e.code === 'EPERM')) {
|
|
464
|
+
try {
|
|
465
|
+
process.stderr.write(`cap-memory-bridge: cache write failed after retries (${e.code}); continuing with stale cache\n`);
|
|
466
|
+
} catch (_e3) { /* ignore */ }
|
|
467
|
+
}
|
|
468
|
+
return { written: false, entries, reason: 'cache-write-failed' };
|
|
469
|
+
}
|
|
470
|
+
return { written: true, entries, reason: 'wrote' };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// @cap-decision(F-080/followup) F-080-FIX-C: rename retry on EBUSY/EPERM with backoff.
|
|
474
|
+
// On Windows, fs.renameSync can fail with EBUSY (target file open by another process) or
|
|
475
|
+
// EPERM (UAC + concurrent reader). Retry up to 3 times with backoff (50ms, 100ms, 200ms).
|
|
476
|
+
// If the final attempt still fails, throw — the caller logs a warning and discards the tmp.
|
|
477
|
+
// Other error codes (ENOENT, EACCES on the directory, etc.) throw immediately on first try
|
|
478
|
+
// because retry won't help and the caller has its own cleanup path.
|
|
479
|
+
// @cap-risk(reason:sync-backoff-blocks-event-loop) The backoff uses Atomics.wait on a fresh
|
|
480
|
+
// Int32Array as a sync sleep primitive. This is acceptable here because (a) refreshCache is
|
|
481
|
+
// already a synchronous filesystem operation, (b) the maximum total wait is 350ms, and
|
|
482
|
+
// (c) the call site is hook-driven, not user-interactive.
|
|
483
|
+
function _renameWithRetry(srcPath, destPath) {
|
|
484
|
+
const RETRYABLE = new Set(['EBUSY', 'EPERM']);
|
|
485
|
+
const backoffsMs = [50, 100, 200];
|
|
486
|
+
let lastErr = null;
|
|
487
|
+
for (let attempt = 0; attempt <= backoffsMs.length; attempt++) {
|
|
488
|
+
try {
|
|
489
|
+
fs.renameSync(srcPath, destPath);
|
|
490
|
+
return;
|
|
491
|
+
} catch (e) {
|
|
492
|
+
lastErr = e;
|
|
493
|
+
if (!e || !RETRYABLE.has(e.code)) throw e;
|
|
494
|
+
if (attempt === backoffsMs.length) break;
|
|
495
|
+
// Sync sleep: Atomics.wait on a fresh shared int32 — guaranteed to time out.
|
|
496
|
+
try {
|
|
497
|
+
const buf = new Int32Array(new SharedArrayBuffer(4));
|
|
498
|
+
Atomics.wait(buf, 0, 0, backoffsMs[attempt]);
|
|
499
|
+
} catch (_e) {
|
|
500
|
+
// SharedArrayBuffer / Atomics may be unavailable in some sandboxed environments —
|
|
501
|
+
// fall back to a busy-loop. Bounded by the same backoff window.
|
|
502
|
+
const deadline = Date.now() + backoffsMs[attempt];
|
|
503
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
throw lastErr;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// -------- Bridge data assembly --------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* @typedef {Object} BridgeData
|
|
514
|
+
* @property {boolean} available - false = silent skip; true = entries usable
|
|
515
|
+
* @property {ClaudeNativeEntry[]} entries
|
|
516
|
+
* @property {string} reason - 'ok' | 'no-claude-native-dir' | 'no-memory-md' | 'parse-empty' | 'unreadable'
|
|
517
|
+
*/
|
|
518
|
+
|
|
519
|
+
// @cap-todo(ac:F-080/AC-3) getBridgeData is the silent-skip-aware entry point. Returns
|
|
520
|
+
// {available:false} for any failure path; never throws, never logs to stdout/stderr.
|
|
521
|
+
//
|
|
522
|
+
// @cap-decision(F-080/AC-3) "Silent skip" is REAL silent: zero output to stdout/stderr.
|
|
523
|
+
// The `reason` field carries the diagnostic for tests / debug logging. A future debug
|
|
524
|
+
// hook can opt-in to log the reason via env-var (e.g. `CAP_DEBUG=1`) but the default
|
|
525
|
+
// path emits NOTHING.
|
|
526
|
+
/**
|
|
527
|
+
* Single entry point for the bridge. Resolves cache vs source, returns assembled data.
|
|
528
|
+
* Silent on any failure.
|
|
529
|
+
*
|
|
530
|
+
* @param {string} projectRoot
|
|
531
|
+
* @returns {BridgeData}
|
|
532
|
+
*/
|
|
533
|
+
function getBridgeData(projectRoot) {
|
|
534
|
+
/** @type {BridgeData} */
|
|
535
|
+
const skip = (reason) => ({ available: false, entries: [], reason });
|
|
536
|
+
// Wrap EVERYTHING in try/catch — silent-skip means even an unexpected throw must not
|
|
537
|
+
// surface. Belt-and-braces: the called helpers are already defensive, but a typo in
|
|
538
|
+
// future maintenance shouldn't break the surrounding command.
|
|
539
|
+
try {
|
|
540
|
+
let claudeNativeDir;
|
|
541
|
+
try {
|
|
542
|
+
claudeNativeDir = getClaudeNativeDir(projectRoot);
|
|
543
|
+
} catch (_e) {
|
|
544
|
+
return skip('slug-derivation-failed');
|
|
545
|
+
}
|
|
546
|
+
if (!fs.existsSync(claudeNativeDir)) {
|
|
547
|
+
return skip('no-claude-native-dir');
|
|
548
|
+
}
|
|
549
|
+
const memoryMdPath = path.join(claudeNativeDir, 'MEMORY.md');
|
|
550
|
+
if (!fs.existsSync(memoryMdPath)) {
|
|
551
|
+
return skip('no-memory-md');
|
|
552
|
+
}
|
|
553
|
+
let entries;
|
|
554
|
+
if (isCacheValid(projectRoot)) {
|
|
555
|
+
const cached = loadCachedIndex(projectRoot);
|
|
556
|
+
entries = cached ? cached.entries : [];
|
|
557
|
+
} else {
|
|
558
|
+
const refreshed = refreshCache(projectRoot);
|
|
559
|
+
entries = refreshed.entries;
|
|
560
|
+
}
|
|
561
|
+
if (!Array.isArray(entries)) entries = [];
|
|
562
|
+
return { available: true, entries, reason: entries.length === 0 ? 'parse-empty' : 'ok' };
|
|
563
|
+
} catch (_e) {
|
|
564
|
+
// Last-ditch swallow. Silent-skip contract overrides anything else.
|
|
565
|
+
return skip('unexpected-error');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// -------- Surface (priority + max-5 truncation) --------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @typedef {Object} SurfaceResult
|
|
573
|
+
* @property {string[]} bullets - already formatted "- <title>" strings, max 5
|
|
574
|
+
* @property {boolean} truncated - true if input had > 5 candidates
|
|
575
|
+
* @property {ClaudeNativeEntry[]} chosen - the entries that backed the bullets (debug)
|
|
576
|
+
*/
|
|
577
|
+
|
|
578
|
+
// @cap-todo(ac:F-080/AC-4) surfaceForFeature returns the bullet list to print under the
|
|
579
|
+
// "Claude-native erinnert:" header. Pure function: takes projectRoot + activeFeature,
|
|
580
|
+
// returns formatted bullets.
|
|
581
|
+
//
|
|
582
|
+
// @cap-todo(ac:F-080/AC-5) Priority: activeFeature direct match → related_features from
|
|
583
|
+
// per-feature memory file → last 2 globally-recent (by fileMtime desc). Hard-cap 5.
|
|
584
|
+
//
|
|
585
|
+
// @cap-decision(F-080/AC-5/tiebreak) Within a single priority bucket, sort by fileMtime
|
|
586
|
+
// desc, then title asc. Deterministic ordering pinned by tests so future changes can't
|
|
587
|
+
// silently shuffle the surface output.
|
|
588
|
+
/**
|
|
589
|
+
* @param {string} projectRoot
|
|
590
|
+
* @param {string|null} activeFeatureId - F-NNN id (or null = no active feature)
|
|
591
|
+
* @param {{relatedFeatures?:string[]}=} options - test seam: lets tests inject related_features
|
|
592
|
+
* without writing a per-feature memory file. In production, related_features is read from
|
|
593
|
+
* the per-feature file via _readRelatedFeatures.
|
|
594
|
+
* @returns {SurfaceResult}
|
|
595
|
+
*/
|
|
596
|
+
function surfaceForFeature(projectRoot, activeFeatureId, options) {
|
|
597
|
+
const opts = options || {};
|
|
598
|
+
const data = getBridgeData(projectRoot);
|
|
599
|
+
if (!data.available || data.entries.length === 0) {
|
|
600
|
+
return { bullets: [], truncated: false, chosen: [] };
|
|
601
|
+
}
|
|
602
|
+
// Resolve related-features: prefer test-injected, fall back to per-feature file lookup.
|
|
603
|
+
let relatedFeatures = Array.isArray(opts.relatedFeatures)
|
|
604
|
+
? opts.relatedFeatures.filter((f) => typeof f === 'string')
|
|
605
|
+
: null;
|
|
606
|
+
if (relatedFeatures === null && activeFeatureId && schema.FEATURE_ID_RE.test(activeFeatureId)) {
|
|
607
|
+
relatedFeatures = _readRelatedFeatures(projectRoot, activeFeatureId);
|
|
608
|
+
}
|
|
609
|
+
if (!Array.isArray(relatedFeatures)) relatedFeatures = [];
|
|
610
|
+
|
|
611
|
+
// Tier 1: entries mentioning the active feature.
|
|
612
|
+
/** @type {ClaudeNativeEntry[]} */
|
|
613
|
+
const tier1 = [];
|
|
614
|
+
/** @type {ClaudeNativeEntry[]} */
|
|
615
|
+
const tier2 = [];
|
|
616
|
+
/** @type {ClaudeNativeEntry[]} */
|
|
617
|
+
const tier3 = [];
|
|
618
|
+
const seen = new Set();
|
|
619
|
+
const matchesFeature = (entry, fid) => {
|
|
620
|
+
const haystack = `${entry.title}\n${entry.file}\n${entry.hook}\n${entry.description || ''}`.toLowerCase();
|
|
621
|
+
return haystack.includes(fid.toLowerCase());
|
|
622
|
+
};
|
|
623
|
+
// Stable-sort comparator (mtime desc, title asc) used in EVERY tier.
|
|
624
|
+
const tierSort = (a, b) => {
|
|
625
|
+
const ma = a.fileMtime || '';
|
|
626
|
+
const mb = b.fileMtime || '';
|
|
627
|
+
if (ma !== mb) return ma < mb ? 1 : -1; // desc
|
|
628
|
+
if (a.title === b.title) return 0;
|
|
629
|
+
return a.title < b.title ? -1 : 1;
|
|
630
|
+
};
|
|
631
|
+
if (activeFeatureId) {
|
|
632
|
+
for (const e of data.entries) {
|
|
633
|
+
if (seen.has(e.file)) continue;
|
|
634
|
+
if (matchesFeature(e, activeFeatureId)) {
|
|
635
|
+
tier1.push(e);
|
|
636
|
+
seen.add(e.file);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
for (const fid of relatedFeatures) {
|
|
641
|
+
if (!schema.FEATURE_ID_RE.test(fid)) continue;
|
|
642
|
+
for (const e of data.entries) {
|
|
643
|
+
if (seen.has(e.file)) continue;
|
|
644
|
+
if (matchesFeature(e, fid)) {
|
|
645
|
+
tier2.push(e);
|
|
646
|
+
seen.add(e.file);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Tier 3: most-recent globals (by mtime desc, title asc tiebreak), excluding already-seen.
|
|
651
|
+
// @cap-decision(F-080/AC-5/D11) Tier 3 cap at 2 entries (per spec "letzte 2 globale Einträge").
|
|
652
|
+
// This is enforced INSIDE tier3 (not just by the outer MAX_BULLETS) so a future bump of
|
|
653
|
+
// MAX_BULLETS doesn't accidentally widen the global-recents window.
|
|
654
|
+
const TIER3_CAP = 2;
|
|
655
|
+
const remaining = data.entries.filter((e) => !seen.has(e.file));
|
|
656
|
+
remaining.sort(tierSort);
|
|
657
|
+
for (const e of remaining) {
|
|
658
|
+
if (tier3.length >= TIER3_CAP) break;
|
|
659
|
+
tier3.push(e);
|
|
660
|
+
seen.add(e.file);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Sort each tier deterministically. tier1 + tier2: mtime desc / title asc. tier3 already sorted.
|
|
664
|
+
tier1.sort(tierSort);
|
|
665
|
+
tier2.sort(tierSort);
|
|
666
|
+
|
|
667
|
+
// Concatenate priorities and hard-cap.
|
|
668
|
+
const merged = [...tier1, ...tier2, ...tier3];
|
|
669
|
+
const truncated = merged.length > MAX_BULLETS;
|
|
670
|
+
const chosen = merged.slice(0, MAX_BULLETS);
|
|
671
|
+
const bullets = chosen.map((e) => `- ${_safeForOutput(e.title)}`);
|
|
672
|
+
return { bullets, truncated, chosen };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// @cap-todo(ac:F-080/AC-4) formatSurface emits the full surface block. Empty bullets →
|
|
676
|
+
// empty string (caller writes nothing). This is the single source of truth for the
|
|
677
|
+
// surface format so /cap:start and /cap:status produce identical output.
|
|
678
|
+
/**
|
|
679
|
+
* @param {SurfaceResult} surface
|
|
680
|
+
* @returns {string} multi-line string ready to print, or '' when no bullets
|
|
681
|
+
*/
|
|
682
|
+
function formatSurface(surface) {
|
|
683
|
+
if (!surface || !Array.isArray(surface.bullets) || surface.bullets.length === 0) {
|
|
684
|
+
return '';
|
|
685
|
+
}
|
|
686
|
+
const lines = ['Claude-native erinnert:'];
|
|
687
|
+
for (const b of surface.bullets) {
|
|
688
|
+
lines.push(` ${b}`);
|
|
689
|
+
}
|
|
690
|
+
if (surface.truncated) {
|
|
691
|
+
lines.push(` (truncated to ${MAX_BULLETS} of ${surface.bullets.length}+ candidates)`);
|
|
692
|
+
}
|
|
693
|
+
return lines.join('\n');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// -------- Per-feature file → related_features lookup --------
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Read related_features from the per-feature memory file's frontmatter. Best-effort:
|
|
700
|
+
* returns [] on any failure (no file, no frontmatter, no related_features field).
|
|
701
|
+
*
|
|
702
|
+
* @param {string} projectRoot
|
|
703
|
+
* @param {string} featureId
|
|
704
|
+
* @returns {string[]}
|
|
705
|
+
*/
|
|
706
|
+
function _readRelatedFeatures(projectRoot, featureId) {
|
|
707
|
+
if (!schema.FEATURE_ID_RE.test(featureId)) return [];
|
|
708
|
+
const featuresDir = path.join(projectRoot, schema.MEMORY_FEATURES_DIR);
|
|
709
|
+
if (!fs.existsSync(featuresDir)) return [];
|
|
710
|
+
let names;
|
|
711
|
+
try {
|
|
712
|
+
names = fs.readdirSync(featuresDir);
|
|
713
|
+
} catch (_e) {
|
|
714
|
+
return [];
|
|
715
|
+
}
|
|
716
|
+
const prefix = `${featureId}-`;
|
|
717
|
+
let target = null;
|
|
718
|
+
for (const name of names) {
|
|
719
|
+
if (typeof name !== 'string') continue;
|
|
720
|
+
if (!name.endsWith('.md')) continue;
|
|
721
|
+
if (name.startsWith(prefix)) { target = name; break; }
|
|
722
|
+
}
|
|
723
|
+
if (!target) return [];
|
|
724
|
+
let raw;
|
|
725
|
+
try {
|
|
726
|
+
raw = fs.readFileSync(path.join(featuresDir, target), 'utf8');
|
|
727
|
+
} catch (_e) {
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
const file = schema.parseFeatureMemoryFile(raw);
|
|
732
|
+
if (file && file.frontmatter && Array.isArray(file.frontmatter.related_features)) {
|
|
733
|
+
return file.frontmatter.related_features.filter((f) => typeof f === 'string' && schema.FEATURE_ID_RE.test(f));
|
|
734
|
+
}
|
|
735
|
+
} catch (_e) {
|
|
736
|
+
// Malformed frontmatter — fall through to empty.
|
|
737
|
+
}
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// -------- Exports --------
|
|
742
|
+
|
|
743
|
+
module.exports = {
|
|
744
|
+
// Public API
|
|
745
|
+
getProjectSlug,
|
|
746
|
+
getClaudeNativeDir,
|
|
747
|
+
getClaudeNativeMemoryMdPath,
|
|
748
|
+
getCachePath,
|
|
749
|
+
parseMemoryMd,
|
|
750
|
+
loadCachedIndex,
|
|
751
|
+
isCacheValid,
|
|
752
|
+
refreshCache,
|
|
753
|
+
getBridgeData,
|
|
754
|
+
surfaceForFeature,
|
|
755
|
+
formatSurface,
|
|
756
|
+
// Constants
|
|
757
|
+
CACHE_REL_PATH,
|
|
758
|
+
CACHE_SCHEMA_VERSION,
|
|
759
|
+
MAX_BULLETS,
|
|
760
|
+
// Test seams
|
|
761
|
+
_readRelatedFeatures,
|
|
762
|
+
_safeForOutput,
|
|
763
|
+
_renameWithRetry,
|
|
764
|
+
};
|