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,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @cap-feature(feature:F-030, primary:true) Pin/unpin management for @cap-pitfall annotations.
|
|
4
|
+
// Provides the write side of /cap:memory pin / /cap:memory unpin so users can flag a pitfall
|
|
5
|
+
// as "pinned" (exempt from F-027 aging) or clear that flag.
|
|
6
|
+
//
|
|
7
|
+
// @cap-decision Pure string manipulation — finds the matching @cap-pitfall line by content
|
|
8
|
+
// prefix and rewrites its metadata block in place. No whole-file parsing, no AST, no
|
|
9
|
+
// re-annotation pass. This keeps the blast radius of a pin operation to a single line edit
|
|
10
|
+
// and preserves all surrounding context and comments.
|
|
11
|
+
// @cap-decision Match policy: the first @cap-pitfall line whose description STARTS with the
|
|
12
|
+
// user-supplied prefix (trimmed, case-sensitive) wins. Ambiguous prefixes return a
|
|
13
|
+
// multi-match result so the caller can disambiguate rather than guess.
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
|
|
17
|
+
// Matches a line carrying a @cap-pitfall annotation. Captures:
|
|
18
|
+
// [1] leading comment prefix + whitespace (e.g. '// ', '# ')
|
|
19
|
+
// [2] metadata block INCLUDING the surrounding parentheses, or '' if none
|
|
20
|
+
// [3] inner metadata content (without parens), or '' if no parens
|
|
21
|
+
// [4] description (trailing text after the annotation)
|
|
22
|
+
const PITFALL_LINE_RE = /^(\s*(?:\/\/|#|--|;|%)\s*)@cap-pitfall(\(([^)]*)\))?\s*(.*)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} PinResult
|
|
26
|
+
* @property {boolean} changed - True when the file was rewritten
|
|
27
|
+
* @property {('pinned'|'unpinned'|'already-pinned'|'not-pinned'|'not-found'|'ambiguous'|'read-error')} status
|
|
28
|
+
* @property {string|null} file - Absolute file path that was acted on (null on read-error)
|
|
29
|
+
* @property {number|null} line - 1-based line number of the modified annotation (null when not-found/ambiguous)
|
|
30
|
+
* @property {string|null} description - The full description line of the matched pitfall (for display)
|
|
31
|
+
* @property {string[]} candidates - When ambiguous: list of candidate descriptions to help the user pick
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Toggle the `pinned:true` flag on the first @cap-pitfall annotation whose description
|
|
36
|
+
* starts with `contentPrefix` (trimmed, case-sensitive).
|
|
37
|
+
*
|
|
38
|
+
* @param {string} filePath - Absolute path to the source file
|
|
39
|
+
* @param {string} contentPrefix - User-supplied prefix of the pitfall description to match
|
|
40
|
+
* @param {{ pin: boolean, dryRun?: boolean }} opts
|
|
41
|
+
* @returns {PinResult}
|
|
42
|
+
*/
|
|
43
|
+
function pinAnnotation(filePath, contentPrefix, opts) {
|
|
44
|
+
const shouldPin = !!(opts && opts.pin);
|
|
45
|
+
const dryRun = !!(opts && opts.dryRun);
|
|
46
|
+
|
|
47
|
+
/** @type {PinResult} */
|
|
48
|
+
const result = {
|
|
49
|
+
changed: false,
|
|
50
|
+
status: 'not-found',
|
|
51
|
+
file: filePath,
|
|
52
|
+
line: null,
|
|
53
|
+
description: null,
|
|
54
|
+
candidates: [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let content;
|
|
58
|
+
try {
|
|
59
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
60
|
+
} catch (_e) {
|
|
61
|
+
result.status = 'read-error';
|
|
62
|
+
result.file = null;
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lines = content.split('\n');
|
|
67
|
+
const prefix = String(contentPrefix || '').trim();
|
|
68
|
+
|
|
69
|
+
// Find all pitfall candidates whose description starts with prefix.
|
|
70
|
+
const matches = [];
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const m = lines[i].match(PITFALL_LINE_RE);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
const description = (m[4] || '').trim();
|
|
75
|
+
if (description.startsWith(prefix)) {
|
|
76
|
+
matches.push({ lineIndex: i, match: m, description });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (matches.length === 0) return result;
|
|
81
|
+
|
|
82
|
+
if (matches.length > 1) {
|
|
83
|
+
result.status = 'ambiguous';
|
|
84
|
+
result.candidates = matches.map((m) => m.description);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { lineIndex, match, description } = matches[0];
|
|
89
|
+
const leading = match[1];
|
|
90
|
+
const metaInner = (match[3] || '').trim();
|
|
91
|
+
|
|
92
|
+
// Parse the existing metadata tokens. Tokens are comma-separated key:value pairs.
|
|
93
|
+
const tokens = metaInner.length === 0
|
|
94
|
+
? []
|
|
95
|
+
: metaInner.split(',').map((s) => s.trim()).filter(Boolean);
|
|
96
|
+
const hasPinned = tokens.some((t) => /^pinned\s*:\s*true$/.test(t));
|
|
97
|
+
|
|
98
|
+
if (shouldPin && hasPinned) {
|
|
99
|
+
result.status = 'already-pinned';
|
|
100
|
+
result.line = lineIndex + 1;
|
|
101
|
+
result.description = description;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
if (!shouldPin && !hasPinned) {
|
|
105
|
+
result.status = 'not-pinned';
|
|
106
|
+
result.line = lineIndex + 1;
|
|
107
|
+
result.description = description;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const newTokens = shouldPin
|
|
112
|
+
? [...tokens, 'pinned:true']
|
|
113
|
+
: tokens.filter((t) => !/^pinned\s*:\s*true$/.test(t));
|
|
114
|
+
|
|
115
|
+
const newMeta = newTokens.length === 0 ? '' : `(${newTokens.join(', ')})`;
|
|
116
|
+
const trailing = description.length === 0 ? '' : ` ${description}`;
|
|
117
|
+
const rewritten = `${leading}@cap-pitfall${newMeta}${trailing}`;
|
|
118
|
+
|
|
119
|
+
lines[lineIndex] = rewritten;
|
|
120
|
+
if (!dryRun) fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
121
|
+
|
|
122
|
+
result.changed = true;
|
|
123
|
+
result.status = shouldPin ? 'pinned' : 'unpinned';
|
|
124
|
+
result.line = lineIndex + 1;
|
|
125
|
+
result.description = description;
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Convenience wrapper for pinning.
|
|
131
|
+
* @param {string} filePath
|
|
132
|
+
* @param {string} contentPrefix
|
|
133
|
+
* @param {{dryRun?: boolean}} [opts]
|
|
134
|
+
* @returns {PinResult}
|
|
135
|
+
*/
|
|
136
|
+
function pin(filePath, contentPrefix, opts) {
|
|
137
|
+
return pinAnnotation(filePath, contentPrefix, { ...(opts || {}), pin: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convenience wrapper for unpinning.
|
|
142
|
+
* @param {string} filePath
|
|
143
|
+
* @param {string} contentPrefix
|
|
144
|
+
* @param {{dryRun?: boolean}} [opts]
|
|
145
|
+
* @returns {PinResult}
|
|
146
|
+
*/
|
|
147
|
+
function unpin(filePath, contentPrefix, opts) {
|
|
148
|
+
return pinAnnotation(filePath, contentPrefix, { ...(opts || {}), pin: false });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format a PinResult as a single-line status string for the CLI surface.
|
|
153
|
+
* @param {PinResult} result
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function formatResult(result) {
|
|
157
|
+
switch (result.status) {
|
|
158
|
+
case 'pinned':
|
|
159
|
+
return `pinned ${result.file}:${result.line} — "${result.description}"`;
|
|
160
|
+
case 'unpinned':
|
|
161
|
+
return `unpinned ${result.file}:${result.line} — "${result.description}"`;
|
|
162
|
+
case 'already-pinned':
|
|
163
|
+
return `no change: already pinned at ${result.file}:${result.line}`;
|
|
164
|
+
case 'not-pinned':
|
|
165
|
+
return `no change: annotation was not pinned at ${result.file}:${result.line}`;
|
|
166
|
+
case 'not-found':
|
|
167
|
+
return `no @cap-pitfall annotation matching prefix found in ${result.file}`;
|
|
168
|
+
case 'ambiguous':
|
|
169
|
+
return `ambiguous prefix — multiple pitfall annotations matched:\n ${result.candidates.join('\n ')}`;
|
|
170
|
+
case 'read-error':
|
|
171
|
+
return `could not read file`;
|
|
172
|
+
default:
|
|
173
|
+
return `unknown status: ${result.status}`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
pinAnnotation,
|
|
179
|
+
pin,
|
|
180
|
+
unpin,
|
|
181
|
+
formatResult,
|
|
182
|
+
PITFALL_LINE_RE,
|
|
183
|
+
};
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// @cap-feature(feature:F-078, primary:true) Platform-Bucket for Cross-Cutting Decisions —
|
|
2
|
+
// .cap/memory/platform/<topic>.md and .cap/memory/platform/checklists/<subsystem>.md
|
|
3
|
+
//
|
|
4
|
+
// @cap-context This module owns explicit-only platform-bucket file IO. Per AC-2, platform
|
|
5
|
+
// promotion is NEVER automatic from per-feature files: a decision lands here only when it
|
|
6
|
+
// carries a `@cap-decision(platform:<topic>)` tag. The classifier helper for that promotion
|
|
7
|
+
// rule lives next to the file IO so the contract is locked in one place.
|
|
8
|
+
//
|
|
9
|
+
// @cap-context F-077's cap-memory-migrate.cjs already writes simplified platform files via
|
|
10
|
+
// renderPlannedWrite. F-078 layers a stricter schema (auto/manual split matching F-076)
|
|
11
|
+
// and a read API for the resolution path that F-079/F-080 depend on. The migrator continues
|
|
12
|
+
// to own the *write* path during migration; F-078 owns read + classifier + checklist.
|
|
13
|
+
//
|
|
14
|
+
// @cap-decision(F-078/AC-1) Platform topic files reuse F-076's auto-block markers
|
|
15
|
+
// (cap:auto:start/end) so the F-076 parser/serializer round-trips them byte-identical.
|
|
16
|
+
// Alternatives considered: a separate marker pair (cap:platform:start) — rejected because
|
|
17
|
+
// every downstream consumer would have to learn two formats. Single marker contract = single
|
|
18
|
+
// failure surface.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const schema = require('./cap-memory-schema.cjs');
|
|
26
|
+
|
|
27
|
+
// -------- Constants --------
|
|
28
|
+
|
|
29
|
+
// @cap-decision(F-078/D1) Platform tree layout is fixed under .cap/memory/platform/.
|
|
30
|
+
// Topic files live at the tree root; subsystem checklists live one level deeper. This
|
|
31
|
+
// separation is structural, not just naming — `listPlatformTopics` filters out the
|
|
32
|
+
// checklists subdir so a checklist is never mistaken for a topic.
|
|
33
|
+
const MEMORY_PLATFORM_DIR = path.join('.cap', 'memory', 'platform');
|
|
34
|
+
const MEMORY_PLATFORM_CHECKLISTS_DIR = path.join('.cap', 'memory', 'platform', 'checklists');
|
|
35
|
+
|
|
36
|
+
// @cap-decision(F-078/D2) Slug regex matches F-076's TOPIC_RE shape (kebab-case alphanumerics)
|
|
37
|
+
// but is re-defined locally so a future divergence between feature topics and platform topics
|
|
38
|
+
// doesn't silently couple. Both currently use the SAME shape; if that changes, update one,
|
|
39
|
+
// not both.
|
|
40
|
+
const PLATFORM_TOPIC_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
41
|
+
|
|
42
|
+
// @cap-decision(F-078/D3) Subsystem slug matches the same kebab-case shape. Subsystem names
|
|
43
|
+
// are derived from module/folder names (e.g. "memory", "tag-scanner") and that's the same
|
|
44
|
+
// alphabet feature topics use.
|
|
45
|
+
const PLATFORM_SUBSYSTEM_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
46
|
+
|
|
47
|
+
// @cap-risk(F-078) Path traversal: `topic` and `subsystem` end up concatenated into a
|
|
48
|
+
// filesystem path. If they ever contain `..` or `/`, an attacker (or a buggy classifier)
|
|
49
|
+
// could write outside the platform tree. The slug regex EXCLUDES both characters, but we
|
|
50
|
+
// double-check explicitly in _validateSlug() because a lone regex without an anchor check
|
|
51
|
+
// has historically been a foot-gun (cf. F-074/D8 path-traversal lesson).
|
|
52
|
+
function _validateSlug(value, kind, regex) {
|
|
53
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
54
|
+
throw new TypeError(`${kind} must be a non-empty string (got ${typeof value})`);
|
|
55
|
+
}
|
|
56
|
+
// Defense-in-depth: reject path-traversal sigils even if the regex would already catch them.
|
|
57
|
+
if (value.includes('/') || value.includes('\\') || value.includes('..') || value.includes('\0')) {
|
|
58
|
+
throw new TypeError(`${kind} must not contain path separators or traversal sequences (got "${_safeForError(value)}")`);
|
|
59
|
+
}
|
|
60
|
+
if (!regex.test(value)) {
|
|
61
|
+
throw new TypeError(`${kind} must be kebab-case slug matching ${regex} (got "${_safeForError(value)}")`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// @cap-decision(F-078/D4) ANSI/control-byte sanitization for error messages: console.warn /
|
|
66
|
+
// thrown error messages embed the rejected slug. If a malicious input contains ANSI escape
|
|
67
|
+
// codes or backspace bytes, a developer reading logs could be visually misled. Strip
|
|
68
|
+
// non-printable bytes when echoing the value, but keep the raw value out of any actual
|
|
69
|
+
// filesystem path (the validator throws before that point anyway).
|
|
70
|
+
function _safeForError(value) {
|
|
71
|
+
if (typeof value !== 'string') return String(value);
|
|
72
|
+
// Replace any byte outside printable ASCII (excluding DEL) with `?`.
|
|
73
|
+
return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -------- Path helpers --------
|
|
77
|
+
|
|
78
|
+
// @cap-todo(ac:F-078/AC-1) getPlatformTopicPath builds the canonical .cap/memory/platform/<topic>.md path.
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} projectRoot
|
|
81
|
+
* @param {string} topic
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function getPlatformTopicPath(projectRoot, topic) {
|
|
85
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
86
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
87
|
+
}
|
|
88
|
+
_validateSlug(topic, 'topic', PLATFORM_TOPIC_RE);
|
|
89
|
+
return path.join(projectRoot, MEMORY_PLATFORM_DIR, `${topic}.md`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// @cap-todo(ac:F-078/AC-4) getChecklistPath builds the canonical .cap/memory/platform/checklists/<subsystem>.md path.
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} projectRoot
|
|
95
|
+
* @param {string} subsystem
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function getChecklistPath(projectRoot, subsystem) {
|
|
99
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
100
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
101
|
+
}
|
|
102
|
+
_validateSlug(subsystem, 'subsystem', PLATFORM_SUBSYSTEM_RE);
|
|
103
|
+
return path.join(projectRoot, MEMORY_PLATFORM_CHECKLISTS_DIR, `${subsystem}.md`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -------- Read API --------
|
|
107
|
+
|
|
108
|
+
// @cap-todo(ac:F-078/AC-1) loadPlatformTopic reads a topic file and parses it via the F-076 schema parser.
|
|
109
|
+
// Same auto/manual split as per-feature files (AC-1 contract).
|
|
110
|
+
/**
|
|
111
|
+
* Load a platform-topic file. Returns null if the file does not exist (graceful skip).
|
|
112
|
+
* Parses via the F-076 schema parser so the auto/manual split is consistent with
|
|
113
|
+
* per-feature files.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} projectRoot
|
|
116
|
+
* @param {string} topic
|
|
117
|
+
* @returns {{exists:boolean, path:string, file:import('./cap-memory-schema.cjs').FeatureMemoryFile|null, raw:string|null}}
|
|
118
|
+
*/
|
|
119
|
+
function loadPlatformTopic(projectRoot, topic) {
|
|
120
|
+
const fp = getPlatformTopicPath(projectRoot, topic);
|
|
121
|
+
if (!fs.existsSync(fp)) {
|
|
122
|
+
return { exists: false, path: fp, file: null, raw: null };
|
|
123
|
+
}
|
|
124
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
125
|
+
// Reuse the F-076 parser. Platform files don't have a `feature:` field — the parser is
|
|
126
|
+
// resilient to that (parseSimpleYaml ignores missing required keys; only validate*()
|
|
127
|
+
// surfaces them as errors). Callers that want strict schema can opt-in via validate().
|
|
128
|
+
const file = schema.parseFeatureMemoryFile(raw);
|
|
129
|
+
return { exists: true, path: fp, file, raw };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// @cap-todo(ac:F-078/AC-4) loadChecklist reads a subsystem checklist (manual-only by convention; auto-block optional).
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} projectRoot
|
|
135
|
+
* @param {string} subsystem
|
|
136
|
+
* @returns {{exists:boolean, path:string, file:import('./cap-memory-schema.cjs').FeatureMemoryFile|null, raw:string|null}}
|
|
137
|
+
*/
|
|
138
|
+
function loadChecklist(projectRoot, subsystem) {
|
|
139
|
+
const fp = getChecklistPath(projectRoot, subsystem);
|
|
140
|
+
if (!fs.existsSync(fp)) {
|
|
141
|
+
return { exists: false, path: fp, file: null, raw: null };
|
|
142
|
+
}
|
|
143
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
144
|
+
const file = schema.parseFeatureMemoryFile(raw);
|
|
145
|
+
return { exists: true, path: fp, file, raw };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// @cap-todo(ac:F-078/AC-1) listPlatformTopics enumerates topic slugs (excluding the checklists subdir).
|
|
149
|
+
/**
|
|
150
|
+
* List all platform-topic slugs present in .cap/memory/platform/. Excludes:
|
|
151
|
+
* - the `checklists/` subdirectory
|
|
152
|
+
* - any non-`.md` file
|
|
153
|
+
* - any file whose basename does not pass PLATFORM_TOPIC_RE (defensive — corrupt
|
|
154
|
+
* filenames are skipped silently rather than crashing)
|
|
155
|
+
*
|
|
156
|
+
* @param {string} projectRoot
|
|
157
|
+
* @returns {string[]} sorted list of topic slugs
|
|
158
|
+
*/
|
|
159
|
+
function listPlatformTopics(projectRoot) {
|
|
160
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
161
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
162
|
+
}
|
|
163
|
+
const dir = path.join(projectRoot, MEMORY_PLATFORM_DIR);
|
|
164
|
+
if (!fs.existsSync(dir)) return [];
|
|
165
|
+
let entries;
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
168
|
+
} catch (_e) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const topics = [];
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
if (!e || typeof e.name !== 'string') continue;
|
|
174
|
+
// Skip subdirectories (including `checklists/`).
|
|
175
|
+
if (e.isDirectory && e.isDirectory()) continue;
|
|
176
|
+
if (!e.name.endsWith('.md')) continue;
|
|
177
|
+
const slug = e.name.slice(0, -3); // strip .md
|
|
178
|
+
if (!PLATFORM_TOPIC_RE.test(slug)) continue;
|
|
179
|
+
topics.push(slug);
|
|
180
|
+
}
|
|
181
|
+
topics.sort();
|
|
182
|
+
return topics;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// @cap-todo(ac:F-078/AC-4) listChecklists enumerates subsystem slugs from the checklists subdir.
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} projectRoot
|
|
188
|
+
* @returns {string[]} sorted list of subsystem slugs
|
|
189
|
+
*/
|
|
190
|
+
function listChecklists(projectRoot) {
|
|
191
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
192
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
193
|
+
}
|
|
194
|
+
const dir = path.join(projectRoot, MEMORY_PLATFORM_CHECKLISTS_DIR);
|
|
195
|
+
if (!fs.existsSync(dir)) return [];
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const e of entries) {
|
|
204
|
+
if (!e || typeof e.name !== 'string') continue;
|
|
205
|
+
if (e.isDirectory && e.isDirectory()) continue;
|
|
206
|
+
if (!e.name.endsWith('.md')) continue;
|
|
207
|
+
const slug = e.name.slice(0, -3);
|
|
208
|
+
if (!PLATFORM_SUBSYSTEM_RE.test(slug)) continue;
|
|
209
|
+
out.push(slug);
|
|
210
|
+
}
|
|
211
|
+
out.sort();
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -------- Write API --------
|
|
216
|
+
|
|
217
|
+
// @cap-decision(F-078/D5) Atomic write goes through the existing _atomicWriteFile helper from
|
|
218
|
+
// cap-memory-migrate.cjs (tmp + rename pattern, F-074/D8). Importing the helper rather than
|
|
219
|
+
// re-implementing keeps all V6 writes funneling through ONE choke point — if a future bug
|
|
220
|
+
// fix lands there, F-078 inherits it for free. Trade-off: we depend on cap-memory-migrate's
|
|
221
|
+
// public surface. cap-memory-migrate.cjs exports _atomicWriteFile explicitly for this use.
|
|
222
|
+
const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
|
|
223
|
+
|
|
224
|
+
// @cap-todo(ac:F-078/AC-1) writePlatformTopic atomically writes a topic file, creating parent dirs as needed.
|
|
225
|
+
/**
|
|
226
|
+
* Write a platform-topic file. Returns `{ updated: bool, reason: string }` per F-082's
|
|
227
|
+
* silent-state-update lesson — the caller can tell whether the file was actually changed
|
|
228
|
+
* vs. skipped due to byte-identical no-op.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} projectRoot
|
|
231
|
+
* @param {string} topic
|
|
232
|
+
* @param {string} content - full file content (frontmatter + markers + body)
|
|
233
|
+
* @returns {{updated:boolean, reason:string, path:string}}
|
|
234
|
+
*/
|
|
235
|
+
function writePlatformTopic(projectRoot, topic, content) {
|
|
236
|
+
const fp = getPlatformTopicPath(projectRoot, topic);
|
|
237
|
+
if (typeof content !== 'string') {
|
|
238
|
+
throw new TypeError('content must be a string');
|
|
239
|
+
}
|
|
240
|
+
// Idempotency: skip atomic write if existing content is byte-identical. Mirrors
|
|
241
|
+
// cap-memory-migrate.cjs:_writePlannedFile.
|
|
242
|
+
if (fs.existsSync(fp)) {
|
|
243
|
+
try {
|
|
244
|
+
const existing = fs.readFileSync(fp, 'utf8');
|
|
245
|
+
if (existing === content) {
|
|
246
|
+
return { updated: false, reason: 'byte-identical-noop', path: fp };
|
|
247
|
+
}
|
|
248
|
+
} catch (_e) {
|
|
249
|
+
// fallthrough to write
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
_atomicWriteFile(fp, content);
|
|
253
|
+
return { updated: true, reason: 'wrote', path: fp };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// @cap-todo(ac:F-078/AC-4) writeChecklist atomically writes a subsystem-checklist file.
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} projectRoot
|
|
259
|
+
* @param {string} subsystem
|
|
260
|
+
* @param {string} content
|
|
261
|
+
* @returns {{updated:boolean, reason:string, path:string}}
|
|
262
|
+
*/
|
|
263
|
+
function writeChecklist(projectRoot, subsystem, content) {
|
|
264
|
+
const fp = getChecklistPath(projectRoot, subsystem);
|
|
265
|
+
if (typeof content !== 'string') {
|
|
266
|
+
throw new TypeError('content must be a string');
|
|
267
|
+
}
|
|
268
|
+
if (fs.existsSync(fp)) {
|
|
269
|
+
try {
|
|
270
|
+
const existing = fs.readFileSync(fp, 'utf8');
|
|
271
|
+
if (existing === content) {
|
|
272
|
+
return { updated: false, reason: 'byte-identical-noop', path: fp };
|
|
273
|
+
}
|
|
274
|
+
} catch (_e) {
|
|
275
|
+
// fallthrough
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
_atomicWriteFile(fp, content);
|
|
279
|
+
return { updated: true, reason: 'wrote', path: fp };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// -------- Render helpers --------
|
|
283
|
+
|
|
284
|
+
// @cap-decision(F-078/D6) renderPlatformTopic builds a canonical platform-topic file using
|
|
285
|
+
// F-076's auto-block markers. Used by the classifier promotion path (and tests). Mirrors
|
|
286
|
+
// the shape produced by cap-memory-migrate.cjs:renderPlannedWrite for platform writes, but
|
|
287
|
+
// is exposed as a pure function so F-078 callers don't have to depend on the migrator.
|
|
288
|
+
/**
|
|
289
|
+
* @param {{topic:string, decisions?:Array<{text:string, location?:string}>, pitfalls?:Array<{text:string, location?:string}>, lessons?:string, updated?:string}} input
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
function renderPlatformTopic(input) {
|
|
293
|
+
if (!input || typeof input !== 'object') {
|
|
294
|
+
throw new TypeError('renderPlatformTopic: input must be an object');
|
|
295
|
+
}
|
|
296
|
+
_validateSlug(input.topic, 'topic', PLATFORM_TOPIC_RE);
|
|
297
|
+
const updated = input.updated || new Date().toISOString();
|
|
298
|
+
const decisions = (input.decisions || []).map((d) => ({
|
|
299
|
+
text: String(d.text || '').replace(/[\r\n]+/g, ' ').trim(),
|
|
300
|
+
location: String(d.location || '').replace(/[\r\n]+/g, ' ').trim(),
|
|
301
|
+
}));
|
|
302
|
+
const pitfalls = (input.pitfalls || []).map((p) => ({
|
|
303
|
+
text: String(p.text || '').replace(/[\r\n]+/g, ' ').trim(),
|
|
304
|
+
location: String(p.location || '').replace(/[\r\n]+/g, ' ').trim(),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
const fmLines = [
|
|
308
|
+
'---',
|
|
309
|
+
`topic: ${input.topic}`,
|
|
310
|
+
`updated: ${updated}`,
|
|
311
|
+
'---',
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const titleCase = input.topic.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
315
|
+
|
|
316
|
+
// @cap-decision(F-078/D7) Empty auto-block: when both decisions and pitfalls are empty, we
|
|
317
|
+
// still emit the marker pair on their own lines (with one blank line between) rather than
|
|
318
|
+
// omitting the auto-block entirely. Reason: the F-076 schema validator accepts empty
|
|
319
|
+
// marker bodies, and downstream re-runs of the migrator will write into the marker pair
|
|
320
|
+
// without needing to re-introduce it. F-076 fixture tests already cover this shape.
|
|
321
|
+
const autoLines = [schema.AUTO_BLOCK_START_MARKER];
|
|
322
|
+
if (decisions.length > 0) {
|
|
323
|
+
autoLines.push('## Decisions (from tags)');
|
|
324
|
+
for (const d of decisions) {
|
|
325
|
+
const loc = d.location ? ` — \`${d.location}\`` : '';
|
|
326
|
+
autoLines.push(`- ${d.text}${loc}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (pitfalls.length > 0) {
|
|
330
|
+
if (decisions.length > 0) autoLines.push('');
|
|
331
|
+
autoLines.push('## Pitfalls (from tags)');
|
|
332
|
+
for (const p of pitfalls) {
|
|
333
|
+
const loc = p.location ? ` — \`${p.location}\`` : '';
|
|
334
|
+
autoLines.push(`- ${p.text}${loc}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
autoLines.push(schema.AUTO_BLOCK_END_MARKER);
|
|
338
|
+
|
|
339
|
+
const lessonsText = (typeof input.lessons === 'string' && input.lessons.trim().length > 0)
|
|
340
|
+
? input.lessons
|
|
341
|
+
: '<!-- Manual lessons go here. The auto-block above is regenerated by the memory pipeline. -->';
|
|
342
|
+
|
|
343
|
+
const out = [
|
|
344
|
+
fmLines.join('\n'),
|
|
345
|
+
'',
|
|
346
|
+
`# Platform: ${titleCase}`,
|
|
347
|
+
'',
|
|
348
|
+
autoLines.join('\n'),
|
|
349
|
+
'',
|
|
350
|
+
'## Lessons',
|
|
351
|
+
'',
|
|
352
|
+
lessonsText,
|
|
353
|
+
'',
|
|
354
|
+
].join('\n');
|
|
355
|
+
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -------- Classifier (AC-2: explicit-only platform promotion) --------
|
|
360
|
+
|
|
361
|
+
// @cap-feature(feature:F-078) classifyDecisionTag — explicit-only platform promotion gate.
|
|
362
|
+
//
|
|
363
|
+
// @cap-todo(ac:F-078/AC-2) classifyDecisionTag routes a single tag to either feature-bucket,
|
|
364
|
+
// platform-bucket, or rejects it. Plain `@cap-decision` (no platform: key) NEVER lands in
|
|
365
|
+
// the platform bucket. A tag with BOTH feature: and platform: keys is REJECTED with a loud
|
|
366
|
+
// parse-error so the author has to pick one.
|
|
367
|
+
//
|
|
368
|
+
// @cap-decision(F-078/AC-2) Explicit-only: there is no fallback heuristic that promotes a
|
|
369
|
+
// per-feature decision into the platform bucket. F-077 had path-heuristik for *unrouted*
|
|
370
|
+
// V5 entries; that's a different problem (orphan classification). Here, the author has
|
|
371
|
+
// explicitly tagged the location and the answer is unambiguous — no guessing.
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @typedef {Object} ClassifierResult
|
|
375
|
+
* @property {'feature'|'platform'|'unassigned'|'error'} destination
|
|
376
|
+
* @property {string|null} featureId - F-NNN if destination === 'feature'
|
|
377
|
+
* @property {string|null} topic - platform topic slug if destination === 'platform'
|
|
378
|
+
* @property {string} reason - human-readable reason
|
|
379
|
+
* @property {string|null} error - error message if destination === 'error'
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Classify a single @cap-decision tag for routing. Pure function — no IO.
|
|
384
|
+
*
|
|
385
|
+
* Routing rules (priority order):
|
|
386
|
+
* 1. Both feature: AND platform: present → ERROR (loud parse-fail). The author must pick one.
|
|
387
|
+
* 2. platform:<topic> present (and slug-valid) → platform-bucket.
|
|
388
|
+
* 3. feature:<F-NNN> present → feature-bucket.
|
|
389
|
+
* 4. Neither present → unassigned (caller's choice — typically falls back to active feature
|
|
390
|
+
* or unassigned platform topic per F-077).
|
|
391
|
+
*
|
|
392
|
+
* @param {{type?:string, metadata?:Object<string,string>, file?:string, line?:number, description?:string}} tag
|
|
393
|
+
* A CapTag-shaped object as emitted by cap-tag-scanner.cjs.
|
|
394
|
+
* @returns {ClassifierResult}
|
|
395
|
+
*/
|
|
396
|
+
function classifyDecisionTag(tag) {
|
|
397
|
+
// @cap-risk(F-078) Defensive: a malformed tag object (missing metadata) should not crash
|
|
398
|
+
// the classifier — return an `error` result instead so the caller can log + continue.
|
|
399
|
+
if (!tag || typeof tag !== 'object') {
|
|
400
|
+
return { destination: 'error', featureId: null, topic: null, reason: 'invalid-tag-shape', error: 'tag must be an object' };
|
|
401
|
+
}
|
|
402
|
+
// F-078 only governs @cap-decision tags. Other types pass through as 'unassigned' so the
|
|
403
|
+
// classifier is safe to call from a generic loop without pre-filtering.
|
|
404
|
+
if (tag.type !== 'decision') {
|
|
405
|
+
return { destination: 'unassigned', featureId: null, topic: null, reason: 'not-a-decision-tag', error: null };
|
|
406
|
+
}
|
|
407
|
+
const meta = tag.metadata || Object.create(null);
|
|
408
|
+
// Normalize values defensively. parseMetadata in tag-scanner already strips whitespace,
|
|
409
|
+
// but defense-in-depth is cheap.
|
|
410
|
+
const platformRaw = (typeof meta.platform === 'string' && meta.platform !== 'true') ? meta.platform.trim() : null;
|
|
411
|
+
const featureRaw = (typeof meta.feature === 'string' && meta.feature !== 'true') ? meta.feature.trim() : null;
|
|
412
|
+
|
|
413
|
+
// 1. Both present → loud error (AC-2 spec gap fix).
|
|
414
|
+
if (platformRaw && featureRaw) {
|
|
415
|
+
const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
|
|
416
|
+
return {
|
|
417
|
+
destination: 'error',
|
|
418
|
+
featureId: featureRaw,
|
|
419
|
+
topic: platformRaw,
|
|
420
|
+
reason: 'both-feature-and-platform',
|
|
421
|
+
error: `@cap-decision at ${loc} has BOTH feature:${featureRaw} AND platform:${platformRaw} — pick one`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 2. Platform tag present.
|
|
426
|
+
if (platformRaw) {
|
|
427
|
+
if (!PLATFORM_TOPIC_RE.test(platformRaw)) {
|
|
428
|
+
const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
|
|
429
|
+
return {
|
|
430
|
+
destination: 'error',
|
|
431
|
+
featureId: null,
|
|
432
|
+
topic: platformRaw,
|
|
433
|
+
reason: 'invalid-platform-slug',
|
|
434
|
+
error: `@cap-decision at ${loc} has invalid platform topic "${_safeForError(platformRaw)}" (must be kebab-case)`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
destination: 'platform',
|
|
439
|
+
featureId: null,
|
|
440
|
+
topic: platformRaw,
|
|
441
|
+
reason: 'explicit-platform-tag',
|
|
442
|
+
error: null,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 3. Feature tag present.
|
|
447
|
+
if (featureRaw) {
|
|
448
|
+
if (!schema.FEATURE_ID_RE.test(featureRaw)) {
|
|
449
|
+
const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
|
|
450
|
+
return {
|
|
451
|
+
destination: 'error',
|
|
452
|
+
featureId: featureRaw,
|
|
453
|
+
topic: null,
|
|
454
|
+
reason: 'invalid-feature-id',
|
|
455
|
+
error: `@cap-decision at ${loc} has invalid feature id "${_safeForError(featureRaw)}"`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
destination: 'feature',
|
|
460
|
+
featureId: featureRaw,
|
|
461
|
+
topic: null,
|
|
462
|
+
reason: 'explicit-feature-tag',
|
|
463
|
+
error: null,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 4. Neither — caller decides (typically: fall back to activeFeature or unassigned).
|
|
468
|
+
return { destination: 'unassigned', featureId: null, topic: null, reason: 'no-routing-tag', error: null };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// -------- Exports --------
|
|
472
|
+
|
|
473
|
+
module.exports = {
|
|
474
|
+
// public API
|
|
475
|
+
loadPlatformTopic,
|
|
476
|
+
writePlatformTopic,
|
|
477
|
+
listPlatformTopics,
|
|
478
|
+
loadChecklist,
|
|
479
|
+
writeChecklist,
|
|
480
|
+
listChecklists,
|
|
481
|
+
renderPlatformTopic,
|
|
482
|
+
classifyDecisionTag,
|
|
483
|
+
getPlatformTopicPath,
|
|
484
|
+
getChecklistPath,
|
|
485
|
+
// constants
|
|
486
|
+
MEMORY_PLATFORM_DIR,
|
|
487
|
+
MEMORY_PLATFORM_CHECKLISTS_DIR,
|
|
488
|
+
PLATFORM_TOPIC_RE,
|
|
489
|
+
PLATFORM_SUBSYSTEM_RE,
|
|
490
|
+
};
|