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,1028 @@
|
|
|
1
|
+
// @cap-feature(feature:F-084, primary:true) Project Onboarding & Migration Orchestrator —
|
|
2
|
+
// state-machine + planner + atomic marker writer for `/cap:upgrade`.
|
|
3
|
+
//
|
|
4
|
+
// @cap-context This module owns the planner / state-manager half of /cap:upgrade.
|
|
5
|
+
// The markdown command spec at commands/cap/upgrade.md is the orchestrator that
|
|
6
|
+
// invokes each /cap:* sub-command in turn; this module decides WHICH stages need
|
|
7
|
+
// to run, in what ORDER, and persists the marker `.cap/version` plus the per-stage
|
|
8
|
+
// audit log `.cap/upgrade.log`. The module never spawns child processes itself —
|
|
9
|
+
// it returns a STAGE-PLAN that the markdown spec executes.
|
|
10
|
+
//
|
|
11
|
+
// @cap-context F-084 is "candidate 8" in the V6 Stage-2 streak. All 12 Stage-2
|
|
12
|
+
// classes are applied UPFRONT (proto-pollution defense, ANSI defense, path-traversal
|
|
13
|
+
// rejection, silent-skip-is-real-silent, atomic writes, round-trip stability, etc.)
|
|
14
|
+
//
|
|
15
|
+
// @cap-decision(F-084/AC-2) The 7 stage names are fixed and ordered. Order
|
|
16
|
+
// is a contract (doctor → init → annotate → migrate-tags → memory-bootstrap →
|
|
17
|
+
// migrate-snapshots → refresh-docs). Skip-conditions decide whether each stage
|
|
18
|
+
// runs but never reorder them. A future stage can be appended to the end.
|
|
19
|
+
//
|
|
20
|
+
// @cap-decision(F-084/AC-4) Per-stage isolation: a failed stage is logged and
|
|
21
|
+
// the orchestrator keeps going. Tests cover this via per-stage error injection.
|
|
22
|
+
//
|
|
23
|
+
// @cap-decision(F-084/AC-5) Marker file `.cap/version` is JSON, not a flat
|
|
24
|
+
// semver string. Rationale: we need to persist completedStages + lastRun
|
|
25
|
+
// alongside version. JSON beats inventing a custom multi-line format.
|
|
26
|
+
//
|
|
27
|
+
// @cap-decision(F-084/spec-gap) cap-upgrade.cjs returns a STAGE-PLAN and a
|
|
28
|
+
// recordStageResult() side-effect API. The markdown command spec
|
|
29
|
+
// (/cap:upgrade) is responsible for actually invoking /cap:doctor, /cap:init,
|
|
30
|
+
// /cap:annotate, etc. for each planned stage. This avoids the foot-gun of
|
|
31
|
+
// JS-from-markdown subprocess invocation while still keeping the planner
|
|
32
|
+
// fully testable in node:test (no spawn needed for unit tests).
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
|
|
39
|
+
|
|
40
|
+
// -------- Constants --------
|
|
41
|
+
|
|
42
|
+
// @cap-decision(F-084/D1) Marker file path is fixed: `.cap/version`. Lives at the
|
|
43
|
+
// same depth as `.cap/SESSION.json` and `.cap/upgrade.log` so the whole upgrade
|
|
44
|
+
// surface is one directory.
|
|
45
|
+
const MARKER_REL_PATH = path.join('.cap', 'version');
|
|
46
|
+
|
|
47
|
+
// @cap-decision(F-084/D2) Audit log path: `.cap/upgrade.log`. JSONL (one JSON
|
|
48
|
+
// object per line). JSONL plays nicely with `tail -f` and `jq -s '.'` for
|
|
49
|
+
// post-mortem and is append-only by construction (no rewrite on each entry).
|
|
50
|
+
const LOG_REL_PATH = path.join('.cap', 'upgrade.log');
|
|
51
|
+
|
|
52
|
+
// @cap-decision(F-084/D3) Hook-throttle marker path: `.cap/.session-advisories.json`.
|
|
53
|
+
// Leading-dot signals "transient/derived" (matches `.cap/memory/.last-run`,
|
|
54
|
+
// `.cap/memory/.claude-native-index.json`). Tracks per-session (process.pid +
|
|
55
|
+
// session-id) advisory emissions so SessionStart-hook only emits once per session.
|
|
56
|
+
const ADVISORY_REL_PATH = path.join('.cap', '.session-advisories.json');
|
|
57
|
+
|
|
58
|
+
// @cap-decision(F-084/D4) Schema version for the marker file. Bump on shape change
|
|
59
|
+
// (e.g. if we add a new field that older readers can't parse). Same pattern as
|
|
60
|
+
// CACHE_SCHEMA_VERSION in cap-memory-bridge.cjs.
|
|
61
|
+
const MARKER_SCHEMA_VERSION = 1;
|
|
62
|
+
|
|
63
|
+
// @cap-decision(F-084/AC-2) Fixed stage list — the contract for the orchestrator.
|
|
64
|
+
// Order is doctor first (read-only health check, gate for everything else), then
|
|
65
|
+
// init-or-skip (foundational), then the 5 modification stages.
|
|
66
|
+
// @cap-decision(F-084/AC-3) The `optional` flag drives `--non-interactive` safe
|
|
67
|
+
// defaults: optional stages get auto-skipped in CI mode unless `--include-stages`
|
|
68
|
+
// re-enables them.
|
|
69
|
+
const STAGES = Object.freeze([
|
|
70
|
+
Object.freeze({ name: 'doctor', command: '/cap:doctor', optional: false, readOnly: true }),
|
|
71
|
+
Object.freeze({ name: 'init-or-skip', command: '/cap:init', optional: false, readOnly: false }),
|
|
72
|
+
Object.freeze({ name: 'annotate', command: '/cap:annotate', optional: true, readOnly: false }),
|
|
73
|
+
Object.freeze({ name: 'migrate-tags', command: '/cap:migrate-tags', optional: false, readOnly: false }),
|
|
74
|
+
Object.freeze({ name: 'memory-bootstrap', command: '/cap:memory bootstrap', optional: false, readOnly: false }),
|
|
75
|
+
Object.freeze({ name: 'migrate-snapshots', command: '/cap:memory migrate-snapshots', optional: false, readOnly: false }),
|
|
76
|
+
Object.freeze({ name: 'refresh-docs', command: '/cap:refresh-docs', optional: true, readOnly: false }),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// @cap-decision(F-084/D5) Hard-coded stage-name allowlist for input validation
|
|
80
|
+
// in --skip-stages parsing. Defense-in-depth against path-traversal attempts
|
|
81
|
+
// (`--skip-stages=../etc/passwd`).
|
|
82
|
+
const STAGE_NAMES = Object.freeze(STAGES.map((s) => s.name));
|
|
83
|
+
|
|
84
|
+
// @cap-decision(F-084/D6) Stage-name regex: matches the literal STAGE_NAMES only.
|
|
85
|
+
// Cheaper than a full validator chain; if a stage name fails this regex we know
|
|
86
|
+
// it's not a known stage AND it's not a path-traversal sequence either.
|
|
87
|
+
const STAGE_NAME_RE = /^[a-z]+(?:-[a-z]+)*$/;
|
|
88
|
+
|
|
89
|
+
// -------- Defensive helpers --------
|
|
90
|
+
|
|
91
|
+
// @cap-decision(F-084/D7) ANSI/control-byte sanitization. Mirrors
|
|
92
|
+
// cap-memory-platform.cjs:_safeForError, cap-memory-bridge.cjs:_safeForOutput,
|
|
93
|
+
// cap-snapshot-linkage.cjs:_safeForError. Kept LOCAL so a refactor in one module
|
|
94
|
+
// can't silently weaken the defense in another. Stage-2 #2 lesson.
|
|
95
|
+
function _safeForError(value) {
|
|
96
|
+
let s;
|
|
97
|
+
try {
|
|
98
|
+
s = String(value);
|
|
99
|
+
} catch (_e) {
|
|
100
|
+
return '<unprintable>';
|
|
101
|
+
}
|
|
102
|
+
// Strip non-printable bytes (ANSI CSI, BEL, BS, NUL). Cap at 200 chars to keep
|
|
103
|
+
// log lines bounded — advisory messages are 120 chars max so 200 is generous.
|
|
104
|
+
// eslint-disable-next-line no-control-regex
|
|
105
|
+
s = s.replace(/[\x00-\x1f\x7f]/g, '?');
|
|
106
|
+
if (s.length > 200) s = s.slice(0, 200) + '...';
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// @cap-decision(F-084/D8) Null-prototype reconstruction for any object parsed from
|
|
111
|
+
// JSON that crosses a trust boundary (.cap/version, .cap/.session-advisories.json,
|
|
112
|
+
// .cap/config.json:upgrade). Stage-2 #1 lesson: prevents __proto__ pollution from
|
|
113
|
+
// a malicious or corrupted marker file.
|
|
114
|
+
function _safeJsonParse(raw) {
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(raw);
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
return { ok: false, value: null, reason: 'parse-error' };
|
|
120
|
+
}
|
|
121
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
122
|
+
return { ok: false, value: null, reason: 'shape-mismatch' };
|
|
123
|
+
}
|
|
124
|
+
// Reconstruct with null prototype + only own-enumerable keys. Drops
|
|
125
|
+
// `__proto__` and `constructor` setters silently; they survive a `JSON.parse`
|
|
126
|
+
// as own keys but reconstruction breaks the prototype chain.
|
|
127
|
+
const safe = Object.create(null);
|
|
128
|
+
for (const key of Object.keys(parsed)) {
|
|
129
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
130
|
+
safe[key] = parsed[key];
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, value: safe, reason: 'parsed' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// @cap-todo(ac:F-084/AC-1) _validateProjectRoot guards every public entry-point
|
|
136
|
+
// against NUL-byte / non-string injection. Defense-in-depth even though Node's
|
|
137
|
+
// fs.* would throw; an explicit error is friendlier than the libuv message.
|
|
138
|
+
function _validateProjectRoot(projectRoot) {
|
|
139
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
140
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
141
|
+
}
|
|
142
|
+
if (projectRoot.includes('\0')) {
|
|
143
|
+
throw new TypeError(`projectRoot contains NUL byte (got "${_safeForError(projectRoot)}")`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -------- Version comparison --------
|
|
148
|
+
|
|
149
|
+
// @cap-decision(F-084/D9) Semver compare without external deps. Accepts
|
|
150
|
+
// `MAJOR.MINOR.PATCH` with optional pre-release suffix (we ignore the suffix
|
|
151
|
+
// for ordering — the ship-on-main contract means we never publish alpha
|
|
152
|
+
// markers). Returns -1 / 0 / +1.
|
|
153
|
+
// @cap-risk(reason:semver-edge-cases) Pre-release ordering is intentionally
|
|
154
|
+
// a no-op here: a comparison with a pre-release suffix degrades to plain
|
|
155
|
+
// numeric compare on the MAJOR.MINOR.PATCH triple. Acceptable: CAP releases
|
|
156
|
+
// are stable-only on main.
|
|
157
|
+
function _parseSemver(v) {
|
|
158
|
+
if (typeof v !== 'string') return null;
|
|
159
|
+
// Strip leading `v` and any pre-release/build suffix.
|
|
160
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
|
|
161
|
+
if (!m) return null;
|
|
162
|
+
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// @cap-todo(ac:F-084/AC-1) compareVersions returns -1/0/+1 / null on parse-failure.
|
|
166
|
+
function compareVersions(a, b) {
|
|
167
|
+
const pa = _parseSemver(a);
|
|
168
|
+
const pb = _parseSemver(b);
|
|
169
|
+
if (!pa || !pb) return null;
|
|
170
|
+
for (let i = 0; i < 3; i++) {
|
|
171
|
+
if (pa[i] < pb[i]) return -1;
|
|
172
|
+
if (pa[i] > pb[i]) return 1;
|
|
173
|
+
}
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// -------- Installed version --------
|
|
178
|
+
|
|
179
|
+
// @cap-todo(ac:F-084/AC-1) getInstalledVersion reads the package.json:version
|
|
180
|
+
// from the CAP repo (or installed npx tree). Falls back to '0.0.0' on missing
|
|
181
|
+
// package.json so the planner stays operational on partial installs.
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} packageJsonDir - dir containing package.json (defaults to CAP install dir)
|
|
184
|
+
* @returns {string} semver string (or '0.0.0' on missing/unreadable)
|
|
185
|
+
*/
|
|
186
|
+
function getInstalledVersion(packageJsonDir) {
|
|
187
|
+
// Default to the CAP install dir (two levels up from this file: .../cap/bin/lib/ -> .../).
|
|
188
|
+
const dir = packageJsonDir || path.resolve(__dirname, '..', '..', '..');
|
|
189
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
190
|
+
if (!fs.existsSync(pkgPath)) return '0.0.0';
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = fs.readFileSync(pkgPath, 'utf8');
|
|
194
|
+
} catch (_e) {
|
|
195
|
+
return '0.0.0';
|
|
196
|
+
}
|
|
197
|
+
const parsed = _safeJsonParse(raw);
|
|
198
|
+
if (!parsed.ok) return '0.0.0';
|
|
199
|
+
const v = parsed.value.version;
|
|
200
|
+
if (typeof v !== 'string') return '0.0.0';
|
|
201
|
+
// Validate the shape — return '0.0.0' if it's not a clean semver.
|
|
202
|
+
return _parseSemver(v) ? v : '0.0.0';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// -------- Marker IO --------
|
|
206
|
+
|
|
207
|
+
// @cap-todo(ac:F-084/AC-5) getMarkerVersion reads `.cap/version` and returns
|
|
208
|
+
// a normalized payload, or null if the file is missing. Stage-2 #1 + #10
|
|
209
|
+
// lessons: corrupted JSON / wrong-shape JSON / missing file all return null
|
|
210
|
+
// gracefully (never throw). The caller treats null as "first run".
|
|
211
|
+
/**
|
|
212
|
+
* @typedef {Object} MarkerPayload
|
|
213
|
+
* @property {number} schemaVersion
|
|
214
|
+
* @property {string} version - last-run CAP version (semver)
|
|
215
|
+
* @property {string[]} completedStages - stage names completed successfully
|
|
216
|
+
* @property {string|null} lastRun - ISO timestamp of last successful upgrade
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} projectRoot
|
|
221
|
+
* @returns {MarkerPayload|null} null if marker missing, corrupted, or unreadable.
|
|
222
|
+
*/
|
|
223
|
+
function getMarkerVersion(projectRoot) {
|
|
224
|
+
_validateProjectRoot(projectRoot);
|
|
225
|
+
const fp = path.join(projectRoot, MARKER_REL_PATH);
|
|
226
|
+
if (!fs.existsSync(fp)) return null;
|
|
227
|
+
let raw;
|
|
228
|
+
try {
|
|
229
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
230
|
+
} catch (_e) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const parsed = _safeJsonParse(raw);
|
|
234
|
+
if (!parsed.ok) return null;
|
|
235
|
+
const obj = parsed.value;
|
|
236
|
+
// Validate fields. Anything malformed → null (treat as first-run).
|
|
237
|
+
if (typeof obj.version !== 'string' || !_parseSemver(obj.version)) return null;
|
|
238
|
+
if (!Array.isArray(obj.completedStages)) return null;
|
|
239
|
+
// Sanitize completedStages: drop any non-string or unknown stage names.
|
|
240
|
+
const completedStages = obj.completedStages
|
|
241
|
+
.filter((s) => typeof s === 'string' && STAGE_NAMES.includes(s));
|
|
242
|
+
const lastRun = (typeof obj.lastRun === 'string') ? obj.lastRun : null;
|
|
243
|
+
const schemaVersion = (typeof obj.schemaVersion === 'number')
|
|
244
|
+
? obj.schemaVersion : MARKER_SCHEMA_VERSION;
|
|
245
|
+
return { schemaVersion, version: obj.version, completedStages, lastRun };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// @cap-todo(ac:F-084/AC-5) writeMarker persists `.cap/version` atomically
|
|
249
|
+
// (tmpfile+rename via _atomicWriteFile from cap-memory-migrate). Stage-2 #6
|
|
250
|
+
// lesson: idempotent, byte-identical re-write returns true without churn
|
|
251
|
+
// (skip if content already matches).
|
|
252
|
+
// @cap-decision(F-084/D10) JSON serialization uses 2-space indent + trailing
|
|
253
|
+
// newline so the file is human-friendly to diff (`git log -p .cap/version`).
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} projectRoot
|
|
256
|
+
* @param {MarkerPayload} payload
|
|
257
|
+
* @returns {boolean} true on write success
|
|
258
|
+
*/
|
|
259
|
+
function writeMarker(projectRoot, payload) {
|
|
260
|
+
_validateProjectRoot(projectRoot);
|
|
261
|
+
if (!payload || typeof payload !== 'object') {
|
|
262
|
+
throw new TypeError('writeMarker: payload must be an object');
|
|
263
|
+
}
|
|
264
|
+
if (typeof payload.version !== 'string' || !_parseSemver(payload.version)) {
|
|
265
|
+
throw new TypeError(`writeMarker: payload.version must be a semver (got "${_safeForError(payload.version)}")`);
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(payload.completedStages)) {
|
|
268
|
+
throw new TypeError('writeMarker: payload.completedStages must be an array');
|
|
269
|
+
}
|
|
270
|
+
// Filter completedStages to known stages only. Unknown stage names are silently
|
|
271
|
+
// dropped — defense-in-depth against caller mistakes.
|
|
272
|
+
const completedStages = payload.completedStages.filter(
|
|
273
|
+
(s) => typeof s === 'string' && STAGE_NAMES.includes(s)
|
|
274
|
+
);
|
|
275
|
+
const safe = {
|
|
276
|
+
schemaVersion: MARKER_SCHEMA_VERSION,
|
|
277
|
+
version: payload.version,
|
|
278
|
+
completedStages,
|
|
279
|
+
lastRun: typeof payload.lastRun === 'string' ? payload.lastRun : new Date().toISOString(),
|
|
280
|
+
};
|
|
281
|
+
const fp = path.join(projectRoot, MARKER_REL_PATH);
|
|
282
|
+
const content = JSON.stringify(safe, null, 2) + '\n';
|
|
283
|
+
// Idempotency: if the file already has byte-identical content, skip the write.
|
|
284
|
+
// _atomicWriteFile would succeed but mtime would update — keep mtime stable on no-op.
|
|
285
|
+
if (fs.existsSync(fp)) {
|
|
286
|
+
try {
|
|
287
|
+
const existing = fs.readFileSync(fp, 'utf8');
|
|
288
|
+
if (existing === content) return true;
|
|
289
|
+
} catch (_e) { /* fall through to write */ }
|
|
290
|
+
}
|
|
291
|
+
_atomicWriteFile(fp, content);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -------- Audit log --------
|
|
296
|
+
|
|
297
|
+
// @cap-todo(ac:F-084/AC-4) appendLog adds one JSONL entry per stage attempt.
|
|
298
|
+
// Append-only, atomic per-line (writeFileSync with `flag: 'a'` is atomic for
|
|
299
|
+
// small writes < PIPE_BUF; we keep entries < 4 KB to stay within that bound).
|
|
300
|
+
// @cap-decision(F-084/D11) NOT atomic-via-rename: the log is append-only, and
|
|
301
|
+
// using tmp+rename for an append would either (a) require reading + rewriting
|
|
302
|
+
// the entire file each time (bad for large logs) or (b) lose history. The
|
|
303
|
+
// append-with-flag pattern is the standard Unix log-append idiom.
|
|
304
|
+
/**
|
|
305
|
+
* @param {string} projectRoot
|
|
306
|
+
* @param {Object} entry - {stage, status, reason?, durationMs?, timestamp}
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
function appendLog(projectRoot, entry) {
|
|
310
|
+
_validateProjectRoot(projectRoot);
|
|
311
|
+
if (!entry || typeof entry !== 'object') {
|
|
312
|
+
throw new TypeError('appendLog: entry must be an object');
|
|
313
|
+
}
|
|
314
|
+
if (typeof entry.stage !== 'string' || !STAGE_NAMES.includes(entry.stage)) {
|
|
315
|
+
throw new TypeError(`appendLog: entry.stage must be a known stage (got "${_safeForError(entry.stage)}")`);
|
|
316
|
+
}
|
|
317
|
+
if (typeof entry.status !== 'string' || !['success', 'failure', 'skipped'].includes(entry.status)) {
|
|
318
|
+
throw new TypeError(`appendLog: entry.status must be success|failure|skipped (got "${_safeForError(entry.status)}")`);
|
|
319
|
+
}
|
|
320
|
+
const safe = {
|
|
321
|
+
stage: entry.stage,
|
|
322
|
+
status: entry.status,
|
|
323
|
+
timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(),
|
|
324
|
+
};
|
|
325
|
+
if (entry.reason != null) safe.reason = _safeForError(entry.reason);
|
|
326
|
+
if (typeof entry.durationMs === 'number' && Number.isFinite(entry.durationMs)) {
|
|
327
|
+
safe.durationMs = Math.max(0, Math.floor(entry.durationMs));
|
|
328
|
+
}
|
|
329
|
+
const fp = path.join(projectRoot, LOG_REL_PATH);
|
|
330
|
+
// Ensure parent dir exists.
|
|
331
|
+
try {
|
|
332
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
333
|
+
} catch (_e) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const line = JSON.stringify(safe) + '\n';
|
|
337
|
+
try {
|
|
338
|
+
fs.writeFileSync(fp, line, { encoding: 'utf8', flag: 'a' });
|
|
339
|
+
} catch (_e) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// @cap-todo(ac:F-084/AC-7) readLog returns the parsed entries from
|
|
346
|
+
// `.cap/upgrade.log`. Used by tests + by the markdown command for post-run
|
|
347
|
+
// summaries. Malformed lines are dropped (Stage-2 #10 lesson).
|
|
348
|
+
function readLog(projectRoot) {
|
|
349
|
+
_validateProjectRoot(projectRoot);
|
|
350
|
+
const fp = path.join(projectRoot, LOG_REL_PATH);
|
|
351
|
+
if (!fs.existsSync(fp)) return [];
|
|
352
|
+
let raw;
|
|
353
|
+
try {
|
|
354
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
355
|
+
} catch (_e) {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const line of raw.split('\n')) {
|
|
360
|
+
const trimmed = line.trim();
|
|
361
|
+
if (!trimmed) continue;
|
|
362
|
+
const parsed = _safeJsonParse(trimmed);
|
|
363
|
+
if (!parsed.ok) continue;
|
|
364
|
+
out.push(parsed.value);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// -------- Plan stages --------
|
|
370
|
+
|
|
371
|
+
// @cap-todo(ac:F-084/AC-2) skip-condition predicates per stage. Each predicate
|
|
372
|
+
// receives (projectRoot, opts) and returns {skip: boolean, reason: string}.
|
|
373
|
+
// @cap-decision(F-084/D12) Predicates are PURE FILESYSTEM CHECKS — they never
|
|
374
|
+
// invoke the actual stage. This keeps planMigrations cheap (sub-millisecond)
|
|
375
|
+
// and free of side effects so tests can fixture per-stage state without spawning.
|
|
376
|
+
const SKIP_PREDICATES = Object.freeze({
|
|
377
|
+
doctor: (_projectRoot, _opts) => ({ skip: false, reason: 'doctor always runs (read-only health check)' }),
|
|
378
|
+
|
|
379
|
+
'init-or-skip': (projectRoot, _opts) => {
|
|
380
|
+
const capDir = path.join(projectRoot, '.cap');
|
|
381
|
+
const featureMap = path.join(projectRoot, 'FEATURE-MAP.md');
|
|
382
|
+
if (fs.existsSync(capDir) && fs.existsSync(featureMap)) {
|
|
383
|
+
return { skip: true, reason: '.cap/ + FEATURE-MAP.md already present' };
|
|
384
|
+
}
|
|
385
|
+
return { skip: false, reason: 'fresh project — needs init' };
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
annotate: (projectRoot, opts) => {
|
|
389
|
+
// @cap-decision(F-084/AC-3) annotate is OPTIONAL. In non-interactive mode
|
|
390
|
+
// skip by default. The user opts in via --include-stages=annotate.
|
|
391
|
+
if (opts && opts.nonInteractive && !opts.includeStages.has('annotate')) {
|
|
392
|
+
return { skip: true, reason: 'non-interactive mode skips optional annotate' };
|
|
393
|
+
}
|
|
394
|
+
if (opts && opts.skipStages.has('annotate')) {
|
|
395
|
+
return { skip: true, reason: 'user requested --skip-stages=annotate' };
|
|
396
|
+
}
|
|
397
|
+
return { skip: false, reason: 'annotate not skipped' };
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
'migrate-tags': (_projectRoot, opts) => {
|
|
401
|
+
if (opts && opts.skipStages.has('migrate-tags')) {
|
|
402
|
+
return { skip: true, reason: 'user requested --skip-stages=migrate-tags' };
|
|
403
|
+
}
|
|
404
|
+
return { skip: false, reason: 'migrate-tags planned' };
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
'memory-bootstrap': (projectRoot, opts) => {
|
|
408
|
+
if (opts && opts.skipStages.has('memory-bootstrap')) {
|
|
409
|
+
return { skip: true, reason: 'user requested --skip-stages=memory-bootstrap' };
|
|
410
|
+
}
|
|
411
|
+
const featuresDir = path.join(projectRoot, '.cap', 'memory', 'features');
|
|
412
|
+
if (fs.existsSync(featuresDir)) {
|
|
413
|
+
try {
|
|
414
|
+
const entries = fs.readdirSync(featuresDir).filter((e) => e.endsWith('.md'));
|
|
415
|
+
if (entries.length > 0) {
|
|
416
|
+
return { skip: true, reason: `.cap/memory/features/ already populated (${entries.length} files)` };
|
|
417
|
+
}
|
|
418
|
+
} catch (_e) { /* fall through */ }
|
|
419
|
+
}
|
|
420
|
+
return { skip: false, reason: 'memory-bootstrap planned' };
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
'migrate-snapshots': (projectRoot, opts) => {
|
|
424
|
+
if (opts && opts.skipStages.has('migrate-snapshots')) {
|
|
425
|
+
return { skip: true, reason: 'user requested --skip-stages=migrate-snapshots' };
|
|
426
|
+
}
|
|
427
|
+
const snapshotsDir = path.join(projectRoot, '.cap', 'snapshots');
|
|
428
|
+
if (!fs.existsSync(snapshotsDir)) {
|
|
429
|
+
return { skip: true, reason: '.cap/snapshots/ absent — nothing to migrate' };
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const entries = fs.readdirSync(snapshotsDir).filter((e) => e.endsWith('.md'));
|
|
433
|
+
if (entries.length === 0) {
|
|
434
|
+
return { skip: true, reason: '.cap/snapshots/ empty' };
|
|
435
|
+
}
|
|
436
|
+
} catch (_e) { /* fall through */ }
|
|
437
|
+
return { skip: false, reason: 'migrate-snapshots planned' };
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
'refresh-docs': (projectRoot, opts) => {
|
|
441
|
+
// @cap-decision(F-084/AC-3) refresh-docs is OPTIONAL — slow + needs network.
|
|
442
|
+
// Non-interactive skips by default; --include-stages=refresh-docs opts in.
|
|
443
|
+
if (opts && opts.nonInteractive && !opts.includeStages.has('refresh-docs')) {
|
|
444
|
+
return { skip: true, reason: 'non-interactive mode skips optional refresh-docs' };
|
|
445
|
+
}
|
|
446
|
+
if (opts && opts.skipStages.has('refresh-docs')) {
|
|
447
|
+
return { skip: true, reason: 'user requested --skip-stages=refresh-docs' };
|
|
448
|
+
}
|
|
449
|
+
const stackDir = path.join(projectRoot, '.cap', 'stack-docs');
|
|
450
|
+
if (fs.existsSync(stackDir)) {
|
|
451
|
+
try {
|
|
452
|
+
const entries = fs.readdirSync(stackDir).filter((e) => e.endsWith('.md'));
|
|
453
|
+
if (entries.length > 0) {
|
|
454
|
+
// Check mtime — anything fresher than 30 days passes.
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
457
|
+
let stale = false;
|
|
458
|
+
for (const e of entries) {
|
|
459
|
+
try {
|
|
460
|
+
const stat = fs.statSync(path.join(stackDir, e));
|
|
461
|
+
if (now - stat.mtime.getTime() > THIRTY_DAYS_MS) {
|
|
462
|
+
stale = true;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
} catch (_e2) { /* treat as stale */ stale = true; break; }
|
|
466
|
+
}
|
|
467
|
+
if (!stale) {
|
|
468
|
+
return { skip: true, reason: `.cap/stack-docs/ all <30 days old (${entries.length} files)` };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} catch (_e) { /* fall through */ }
|
|
472
|
+
}
|
|
473
|
+
return { skip: false, reason: 'refresh-docs planned (stale or missing)' };
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// @cap-decision(F-084/iter1) Stage-2 #2 fix: per-stage delta-probes implemented (Option A).
|
|
478
|
+
// AC-3 demands "per-stage delta-summary (was wird hinzugefügt/geändert)". Each
|
|
479
|
+
// probe is a quick READ-ONLY filesystem inspection that estimates what the
|
|
480
|
+
// stage would create/modify. Probes MUST be fast (<2s combined) and MUST NOT
|
|
481
|
+
// spawn subprocesses or invoke the actual stage logic. On any error a probe
|
|
482
|
+
// returns null (caller falls back to skip-reason only).
|
|
483
|
+
// @cap-decision(F-084/iter1) Probes are PURE READS — no atomic writes, no marker
|
|
484
|
+
// updates, no log appends. Stage-2 #12 lesson (read-only contract) extended to
|
|
485
|
+
// the dry-run UX layer.
|
|
486
|
+
const DELTA_PROBES = Object.freeze({
|
|
487
|
+
doctor: (_projectRoot) => null, // doctor is read-only health-check; no delta to preview.
|
|
488
|
+
|
|
489
|
+
'init-or-skip': (projectRoot) => {
|
|
490
|
+
// Probe what /cap:init would create.
|
|
491
|
+
const items = [];
|
|
492
|
+
if (!fs.existsSync(path.join(projectRoot, '.cap'))) items.push('.cap/');
|
|
493
|
+
if (!fs.existsSync(path.join(projectRoot, 'FEATURE-MAP.md'))) items.push('FEATURE-MAP.md (skeleton)');
|
|
494
|
+
if (!fs.existsSync(path.join(projectRoot, '.cap', 'config.json'))) items.push('.cap/config.json');
|
|
495
|
+
if (items.length === 0) return null;
|
|
496
|
+
return `Will create: ${items.join(', ')}`;
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
annotate: (projectRoot) => {
|
|
500
|
+
// Estimate scan size: count .js / .cjs / .ts files under common source dirs.
|
|
501
|
+
// @cap-decision(F-084/iter1) annotate probe is an UPPER bound — counts files
|
|
502
|
+
// that COULD be scanned, not files that WILL be tagged. Cheap walk, capped
|
|
503
|
+
// at top-level + first-level depth to stay <100ms.
|
|
504
|
+
const candidateDirs = ['src', 'lib', 'cap/bin/lib', 'hooks', 'sdk/src', 'scripts'];
|
|
505
|
+
let count = 0;
|
|
506
|
+
const exts = new Set(['.js', '.cjs', '.mjs', '.ts', '.tsx']);
|
|
507
|
+
for (const rel of candidateDirs) {
|
|
508
|
+
const dir = path.join(projectRoot, rel);
|
|
509
|
+
if (!fs.existsSync(dir)) continue;
|
|
510
|
+
try {
|
|
511
|
+
// Shallow walk: top-level + one nested level. Bounded by directory entry count.
|
|
512
|
+
const stack = [{ dir, depth: 0 }];
|
|
513
|
+
while (stack.length > 0) {
|
|
514
|
+
const { dir: d, depth } = stack.pop();
|
|
515
|
+
if (depth > 2) continue;
|
|
516
|
+
let entries;
|
|
517
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); }
|
|
518
|
+
catch (_e) { continue; }
|
|
519
|
+
for (const e of entries) {
|
|
520
|
+
if (e.name.startsWith('.')) continue;
|
|
521
|
+
if (e.isDirectory()) {
|
|
522
|
+
if (e.name === 'node_modules' || e.name === 'dist') continue;
|
|
523
|
+
stack.push({ dir: path.join(d, e.name), depth: depth + 1 });
|
|
524
|
+
} else if (exts.has(path.extname(e.name))) {
|
|
525
|
+
count++;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (_e) { /* swallow per-dir errors */ }
|
|
530
|
+
}
|
|
531
|
+
if (count === 0) return null;
|
|
532
|
+
return `Will scan ~${count} source files for tag candidates`;
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
'migrate-tags': (projectRoot) => {
|
|
536
|
+
// Lazy-load the tag scanner; if unavailable, return null.
|
|
537
|
+
let scanner;
|
|
538
|
+
try {
|
|
539
|
+
scanner = require('./cap-tag-scanner.cjs');
|
|
540
|
+
} catch (_e) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
if (typeof scanner.scanDirectory !== 'function') return null;
|
|
544
|
+
let scanResult;
|
|
545
|
+
try {
|
|
546
|
+
scanResult = scanner.scanDirectory(projectRoot);
|
|
547
|
+
} catch (_e) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
// Count fragmented vs anchored tags. The exact shape varies; defensively try
|
|
551
|
+
// common keys. If the scanner doesn't expose fragmentation counts, we report
|
|
552
|
+
// total tag count instead.
|
|
553
|
+
const tags = Array.isArray(scanResult && scanResult.tags) ? scanResult.tags : [];
|
|
554
|
+
if (tags.length === 0) return null;
|
|
555
|
+
let fragmented = 0;
|
|
556
|
+
for (const t of tags) {
|
|
557
|
+
if (t && (t.fragmented === true || t.isFragmented === true)) fragmented++;
|
|
558
|
+
}
|
|
559
|
+
if (fragmented > 0) {
|
|
560
|
+
return `Will migrate ~${fragmented} fragmented tags to anchor blocks`;
|
|
561
|
+
}
|
|
562
|
+
return `Will inspect ${tags.length} tags for fragmentation`;
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
'memory-bootstrap': (projectRoot) => {
|
|
566
|
+
// Count features in FEATURE-MAP.md that lack a per-feature memory file.
|
|
567
|
+
const featureMap = path.join(projectRoot, 'FEATURE-MAP.md');
|
|
568
|
+
if (!fs.existsSync(featureMap)) return null;
|
|
569
|
+
let raw;
|
|
570
|
+
try { raw = fs.readFileSync(featureMap, 'utf8'); } catch (_e) { return null; }
|
|
571
|
+
// Match feature IDs in headers (### F-NNN: ... or ### F-NNN — ...).
|
|
572
|
+
const featureIds = new Set();
|
|
573
|
+
const re = /^###\s+(F-\d{3,})\b/gm;
|
|
574
|
+
let m;
|
|
575
|
+
while ((m = re.exec(raw)) !== null) featureIds.add(m[1]);
|
|
576
|
+
if (featureIds.size === 0) return null;
|
|
577
|
+
const featuresDir = path.join(projectRoot, '.cap', 'memory', 'features');
|
|
578
|
+
let missing = 0;
|
|
579
|
+
for (const fid of featureIds) {
|
|
580
|
+
if (!fs.existsSync(path.join(featuresDir, `${fid}.md`))) missing++;
|
|
581
|
+
}
|
|
582
|
+
if (missing === 0) return null;
|
|
583
|
+
return `Will create ${missing} per-feature memory file${missing === 1 ? '' : 's'}`;
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
'migrate-snapshots': (projectRoot) => {
|
|
587
|
+
const snapshotsDir = path.join(projectRoot, '.cap', 'snapshots');
|
|
588
|
+
if (!fs.existsSync(snapshotsDir)) return null;
|
|
589
|
+
let entries;
|
|
590
|
+
try {
|
|
591
|
+
entries = fs.readdirSync(snapshotsDir).filter((e) => e.endsWith('.md'));
|
|
592
|
+
} catch (_e) { return null; }
|
|
593
|
+
if (entries.length === 0) return null;
|
|
594
|
+
// Count snapshots with no `feature:` front-matter (orphaned / unlinked).
|
|
595
|
+
let unlinked = 0;
|
|
596
|
+
for (const e of entries) {
|
|
597
|
+
try {
|
|
598
|
+
const raw = fs.readFileSync(path.join(snapshotsDir, e), 'utf8');
|
|
599
|
+
// Check first 1 KB only — front-matter is at the top.
|
|
600
|
+
const head = raw.slice(0, 1024);
|
|
601
|
+
if (!/^---[\s\S]*?\bfeature\s*:\s*F-\d/m.test(head)) unlinked++;
|
|
602
|
+
} catch (_e) { /* count as unlinked */ unlinked++; }
|
|
603
|
+
}
|
|
604
|
+
if (unlinked === 0) {
|
|
605
|
+
return `Will inspect ${entries.length} snapshot${entries.length === 1 ? '' : 's'} (all already linked)`;
|
|
606
|
+
}
|
|
607
|
+
return `Will link ${unlinked} of ${entries.length} snapshot${entries.length === 1 ? '' : 's'} to features`;
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
'refresh-docs': (projectRoot) => {
|
|
611
|
+
// Read package.json deps (top-level only).
|
|
612
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
613
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
614
|
+
let raw;
|
|
615
|
+
try { raw = fs.readFileSync(pkgPath, 'utf8'); } catch (_e) { return null; }
|
|
616
|
+
const parsed = _safeJsonParse(raw);
|
|
617
|
+
if (!parsed.ok) return null;
|
|
618
|
+
const deps = Object.assign(
|
|
619
|
+
Object.create(null),
|
|
620
|
+
parsed.value.dependencies && typeof parsed.value.dependencies === 'object' ? parsed.value.dependencies : {},
|
|
621
|
+
parsed.value.devDependencies && typeof parsed.value.devDependencies === 'object' ? parsed.value.devDependencies : {}
|
|
622
|
+
);
|
|
623
|
+
const names = Object.keys(deps).filter((n) => typeof n === 'string' && /^[a-z0-9@/_.-]+$/i.test(n));
|
|
624
|
+
if (names.length === 0) return null;
|
|
625
|
+
// Show first few names, capped — Stage-2 #8 surface-limit lesson.
|
|
626
|
+
const head = names.slice(0, 3).map((n) => _safeForError(n).slice(0, 32));
|
|
627
|
+
const more = names.length > 3 ? `, +${names.length - 3} more` : '';
|
|
628
|
+
return `Will fetch docs for libraries: ${head.join(', ')}${more}`;
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// @cap-decision(F-084/iter1) Probe runner: hard timeout + error isolation. Any
|
|
633
|
+
// probe that throws or returns non-string is treated as null (no delta line).
|
|
634
|
+
function _runProbe(stageName, projectRoot) {
|
|
635
|
+
const probe = DELTA_PROBES[stageName];
|
|
636
|
+
if (typeof probe !== 'function') return null;
|
|
637
|
+
try {
|
|
638
|
+
const out = probe(projectRoot);
|
|
639
|
+
if (typeof out !== 'string') return null;
|
|
640
|
+
return _safeForError(out);
|
|
641
|
+
} catch (_e) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// @cap-todo(ac:F-084/AC-3) _normalizeOptions parses runOptions into the shape
|
|
647
|
+
// the predicates need. Stage-2 #11 lesson: realistic-input testing — accept
|
|
648
|
+
// strings, arrays, undefined. Stage-name strings are validated against the
|
|
649
|
+
// allowlist (Stage-2 path-traversal defense).
|
|
650
|
+
function _normalizeOptions(opts) {
|
|
651
|
+
const o = (opts && typeof opts === 'object') ? opts : {};
|
|
652
|
+
const out = {
|
|
653
|
+
nonInteractive: Boolean(o.nonInteractive),
|
|
654
|
+
forceRerun: Boolean(o.forceRerun),
|
|
655
|
+
dryRunOnly: Boolean(o.dryRunOnly),
|
|
656
|
+
skipStages: new Set(),
|
|
657
|
+
includeStages: new Set(),
|
|
658
|
+
};
|
|
659
|
+
// Parse skipStages — accept array or comma-separated string.
|
|
660
|
+
const skipRaw = o.skipStages;
|
|
661
|
+
let skipList = [];
|
|
662
|
+
if (Array.isArray(skipRaw)) {
|
|
663
|
+
skipList = skipRaw;
|
|
664
|
+
} else if (typeof skipRaw === 'string') {
|
|
665
|
+
skipList = skipRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
666
|
+
}
|
|
667
|
+
for (const name of skipList) {
|
|
668
|
+
// Stage-2 path-traversal: reject anything that doesn't match the allowlist.
|
|
669
|
+
// We DON'T throw — silently drop unknown names + log to stderr in CAP_DEBUG.
|
|
670
|
+
if (typeof name !== 'string') continue;
|
|
671
|
+
if (!STAGE_NAME_RE.test(name)) {
|
|
672
|
+
if (process.env.CAP_DEBUG) {
|
|
673
|
+
try { process.stderr.write(`[cap:debug] cap-upgrade: dropped malformed --skip-stages entry "${_safeForError(name)}"\n`); } catch (_e) { /* ignore */ }
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (!STAGE_NAMES.includes(name)) {
|
|
678
|
+
if (process.env.CAP_DEBUG) {
|
|
679
|
+
try { process.stderr.write(`[cap:debug] cap-upgrade: unknown stage "${_safeForError(name)}" in --skip-stages\n`); } catch (_e) { /* ignore */ }
|
|
680
|
+
}
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
out.skipStages.add(name);
|
|
684
|
+
}
|
|
685
|
+
// Parse includeStages — same as skipStages.
|
|
686
|
+
const includeRaw = o.includeStages;
|
|
687
|
+
let includeList = [];
|
|
688
|
+
if (Array.isArray(includeRaw)) {
|
|
689
|
+
includeList = includeRaw;
|
|
690
|
+
} else if (typeof includeRaw === 'string') {
|
|
691
|
+
includeList = includeRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
692
|
+
}
|
|
693
|
+
for (const name of includeList) {
|
|
694
|
+
if (typeof name !== 'string') continue;
|
|
695
|
+
if (!STAGE_NAME_RE.test(name)) continue;
|
|
696
|
+
if (!STAGE_NAMES.includes(name)) continue;
|
|
697
|
+
out.includeStages.add(name);
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// @cap-todo(ac:F-084/AC-1) planMigrations — the core planner. Reads marker +
|
|
703
|
+
// installed version, then walks STAGES in fixed order, asking each predicate
|
|
704
|
+
// whether to skip. Returns an ordered StagePlan[] with reasons.
|
|
705
|
+
// @cap-decision(F-084/AC-2) Stage execution order is deterministic (matches
|
|
706
|
+
// STAGES array). Even with --skip-stages or --include-stages permutations,
|
|
707
|
+
// the surviving stages keep the same relative order. Stage-2 #9 lesson.
|
|
708
|
+
/**
|
|
709
|
+
* @typedef {Object} StagePlan
|
|
710
|
+
* @property {string} name
|
|
711
|
+
* @property {string} command - /cap:* command to invoke
|
|
712
|
+
* @property {boolean} skip - true if this stage will be skipped
|
|
713
|
+
* @property {string} reason - human-readable explanation
|
|
714
|
+
* @property {boolean} optional - true if optional in non-interactive mode
|
|
715
|
+
* @property {boolean} alreadyDone - true if marker says this stage was completed at the current version
|
|
716
|
+
* @property {string|null} delta - per-stage delta-summary (was wird hinzugefügt/geändert) — null when no probe applies
|
|
717
|
+
*/
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* @param {string} projectRoot
|
|
721
|
+
* @param {{installedVersion?:string, markerData?:MarkerPayload|null, runOptions?:Object}} [args]
|
|
722
|
+
* @returns {{installedVersion:string, markerVersion:string|null, plan:StagePlan[], firstRun:boolean, alreadyCurrent:boolean}}
|
|
723
|
+
*/
|
|
724
|
+
function planMigrations(projectRoot, args) {
|
|
725
|
+
_validateProjectRoot(projectRoot);
|
|
726
|
+
const a = args || {};
|
|
727
|
+
const installedVersion = typeof a.installedVersion === 'string' ? a.installedVersion : getInstalledVersion();
|
|
728
|
+
const markerData = (a.markerData !== undefined) ? a.markerData : getMarkerVersion(projectRoot);
|
|
729
|
+
const runOptions = _normalizeOptions(a.runOptions);
|
|
730
|
+
const firstRun = markerData === null;
|
|
731
|
+
const markerVersion = markerData ? markerData.version : null;
|
|
732
|
+
const completedAtCurrent = (markerData && markerVersion === installedVersion)
|
|
733
|
+
? new Set(markerData.completedStages)
|
|
734
|
+
: new Set();
|
|
735
|
+
// alreadyCurrent: marker version matches installed AND every non-optional stage was completed.
|
|
736
|
+
let alreadyCurrent = false;
|
|
737
|
+
if (markerData && markerVersion === installedVersion && !runOptions.forceRerun) {
|
|
738
|
+
const requiredCompleted = STAGES
|
|
739
|
+
.filter((s) => !s.optional)
|
|
740
|
+
.every((s) => completedAtCurrent.has(s.name));
|
|
741
|
+
alreadyCurrent = requiredCompleted;
|
|
742
|
+
}
|
|
743
|
+
const plan = [];
|
|
744
|
+
for (const stage of STAGES) {
|
|
745
|
+
const predicate = SKIP_PREDICATES[stage.name];
|
|
746
|
+
let result;
|
|
747
|
+
try {
|
|
748
|
+
result = predicate(projectRoot, runOptions);
|
|
749
|
+
} catch (e) {
|
|
750
|
+
// Defensive: a predicate throwing should NOT crash the planner.
|
|
751
|
+
result = { skip: true, reason: `predicate-error: ${_safeForError(e && e.message)}` };
|
|
752
|
+
}
|
|
753
|
+
let skip = Boolean(result.skip);
|
|
754
|
+
let reason = String(result.reason || '');
|
|
755
|
+
// alreadyDone signal: marker says this exact stage was completed at current version.
|
|
756
|
+
const alreadyDone = completedAtCurrent.has(stage.name) && !runOptions.forceRerun;
|
|
757
|
+
if (alreadyDone && !skip) {
|
|
758
|
+
// Marker overrides predicate — the stage was already run at this version.
|
|
759
|
+
skip = true;
|
|
760
|
+
reason = `marker shows stage completed at ${installedVersion}`;
|
|
761
|
+
}
|
|
762
|
+
// @cap-decision(F-084/iter1) Stage-2 #2 fix: per-stage delta-probes implemented (Option A).
|
|
763
|
+
// Probe ONLY for stages that will actually run (skip ones get null). Probes
|
|
764
|
+
// are read-only and degrade gracefully on any error. AC-3: "per-stage
|
|
765
|
+
// delta-summary (was wird hinzugefügt/geändert)".
|
|
766
|
+
let delta = null;
|
|
767
|
+
if (!skip) {
|
|
768
|
+
delta = _runProbe(stage.name, projectRoot);
|
|
769
|
+
}
|
|
770
|
+
plan.push({
|
|
771
|
+
name: stage.name,
|
|
772
|
+
command: stage.command,
|
|
773
|
+
skip,
|
|
774
|
+
reason,
|
|
775
|
+
optional: stage.optional,
|
|
776
|
+
alreadyDone,
|
|
777
|
+
delta,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return { installedVersion, markerVersion, plan, firstRun, alreadyCurrent };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// -------- Stage execution --------
|
|
784
|
+
|
|
785
|
+
// @cap-todo(ac:F-084/AC-4) recordStageResult records the OUTCOME of a single
|
|
786
|
+
// stage attempt. The actual command invocation happens in the markdown
|
|
787
|
+
// orchestrator — this function is the side-effect choke-point that
|
|
788
|
+
// updates the marker + appends the audit log. Stage-2 #4 lesson: per-stage
|
|
789
|
+
// isolation, a failed stage does NOT block subsequent stages.
|
|
790
|
+
// @cap-decision(F-084/iter1) Stage-2 #5 fix: stale comment cleanup. Function
|
|
791
|
+
// was previously named executeStage in earlier drafts; comment now matches.
|
|
792
|
+
/**
|
|
793
|
+
* @param {string} projectRoot
|
|
794
|
+
* @param {string} stageName
|
|
795
|
+
* @param {{status:'success'|'failure'|'skipped', reason?:string, durationMs?:number, installedVersion?:string}} outcome
|
|
796
|
+
* @returns {{logged:boolean, markerUpdated:boolean}}
|
|
797
|
+
*/
|
|
798
|
+
function recordStageResult(projectRoot, stageName, outcome) {
|
|
799
|
+
_validateProjectRoot(projectRoot);
|
|
800
|
+
if (!STAGE_NAMES.includes(stageName)) {
|
|
801
|
+
throw new TypeError(`recordStageResult: unknown stage "${_safeForError(stageName)}"`);
|
|
802
|
+
}
|
|
803
|
+
if (!outcome || typeof outcome !== 'object') {
|
|
804
|
+
throw new TypeError('recordStageResult: outcome must be an object');
|
|
805
|
+
}
|
|
806
|
+
const status = outcome.status;
|
|
807
|
+
if (!['success', 'failure', 'skipped'].includes(status)) {
|
|
808
|
+
throw new TypeError(`recordStageResult: outcome.status must be success|failure|skipped`);
|
|
809
|
+
}
|
|
810
|
+
const timestamp = new Date().toISOString();
|
|
811
|
+
const logged = appendLog(projectRoot, {
|
|
812
|
+
stage: stageName,
|
|
813
|
+
status,
|
|
814
|
+
reason: outcome.reason,
|
|
815
|
+
durationMs: outcome.durationMs,
|
|
816
|
+
timestamp,
|
|
817
|
+
});
|
|
818
|
+
// Marker is only updated on success — failures + skips don't flip the bit.
|
|
819
|
+
let markerUpdated = false;
|
|
820
|
+
if (status === 'success') {
|
|
821
|
+
const installedVersion = typeof outcome.installedVersion === 'string'
|
|
822
|
+
? outcome.installedVersion
|
|
823
|
+
: getInstalledVersion();
|
|
824
|
+
const existing = getMarkerVersion(projectRoot);
|
|
825
|
+
let completedStages;
|
|
826
|
+
if (existing && existing.version === installedVersion) {
|
|
827
|
+
const set = new Set(existing.completedStages);
|
|
828
|
+
set.add(stageName);
|
|
829
|
+
completedStages = STAGE_NAMES.filter((n) => set.has(n)); // deterministic order
|
|
830
|
+
} else {
|
|
831
|
+
// Version bumped (or first marker write) — start a fresh completed list.
|
|
832
|
+
completedStages = [stageName];
|
|
833
|
+
}
|
|
834
|
+
// @cap-decision(F-084/iter1) Stage-2 #4 fix: recordStageResult resilient to
|
|
835
|
+
// writeMarker failures. If disk fills up between stages, writeMarker
|
|
836
|
+
// throws (EROFS, ENOSPC, EPERM, etc). Previously the throw propagated to
|
|
837
|
+
// the orchestrator and crashed the whole upgrade — but the per-stage work
|
|
838
|
+
// already SUCCEEDED and was already logged. The stage was completed; the
|
|
839
|
+
// marker just couldn't be advanced. Wrap in try/catch so the upgrade
|
|
840
|
+
// continues; user can re-run /cap:upgrade and it will detect the partial
|
|
841
|
+
// marker state via predicates and resume.
|
|
842
|
+
try {
|
|
843
|
+
markerUpdated = writeMarker(projectRoot, {
|
|
844
|
+
version: installedVersion,
|
|
845
|
+
completedStages,
|
|
846
|
+
lastRun: timestamp,
|
|
847
|
+
});
|
|
848
|
+
} catch (e) {
|
|
849
|
+
markerUpdated = false;
|
|
850
|
+
// Best-effort: append a marker-failure entry to the log so the audit trail
|
|
851
|
+
// captures it. If THAT also throws (truly broken disk), swallow silently —
|
|
852
|
+
// we are already past the point of useful recovery.
|
|
853
|
+
try {
|
|
854
|
+
const fp = path.join(projectRoot, LOG_REL_PATH);
|
|
855
|
+
const safeMsg = _safeForError(e && e.message);
|
|
856
|
+
const failureEntry = JSON.stringify({
|
|
857
|
+
stage: stageName,
|
|
858
|
+
status: 'marker-write-failure',
|
|
859
|
+
reason: `marker write failed after stage success: ${safeMsg}`,
|
|
860
|
+
timestamp: new Date().toISOString(),
|
|
861
|
+
}) + '\n';
|
|
862
|
+
fs.writeFileSync(fp, failureEntry, { encoding: 'utf8', flag: 'a' });
|
|
863
|
+
} catch (_e2) { /* nothing more we can do */ }
|
|
864
|
+
// Single stderr warning under CAP_DEBUG — silent in normal runs (Stage-2 #4).
|
|
865
|
+
if (process.env.CAP_DEBUG) {
|
|
866
|
+
try {
|
|
867
|
+
process.stderr.write(`[cap:debug] cap-upgrade: marker write failed after stage "${_safeForError(stageName)}" — ${_safeForError(e && e.message)}\n`);
|
|
868
|
+
} catch (_e3) { /* ignore */ }
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return { logged, markerUpdated };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// -------- Hook advisory throttling --------
|
|
876
|
+
|
|
877
|
+
// @cap-todo(ac:F-084/AC-6) shouldEmitAdvisory throttles SessionStart-hook
|
|
878
|
+
// emissions to once per session. Reads `.cap/.session-advisories.json`,
|
|
879
|
+
// looks for an entry keyed by the session-id, and either records a fresh
|
|
880
|
+
// emit OR signals "already emitted this session".
|
|
881
|
+
// @cap-decision(F-084/AC-6) Session ID is taken from $CLAUDE_SESSION_ID
|
|
882
|
+
// (Claude Code injects this into hooks). Fallback to process.ppid + start
|
|
883
|
+
// timestamp so we still throttle within a single shell pipeline run.
|
|
884
|
+
/**
|
|
885
|
+
* @param {string} projectRoot
|
|
886
|
+
* @param {{sessionId?:string, now?:number, configNotify?:boolean|null}} [opts]
|
|
887
|
+
* @returns {{shouldEmit:boolean, reason:string}}
|
|
888
|
+
*/
|
|
889
|
+
function shouldEmitAdvisory(projectRoot, opts) {
|
|
890
|
+
_validateProjectRoot(projectRoot);
|
|
891
|
+
const o = opts || {};
|
|
892
|
+
// Suppression via .cap/config.json:upgrade.notify=false → silent.
|
|
893
|
+
if (o.configNotify === false) {
|
|
894
|
+
return { shouldEmit: false, reason: 'suppressed via config.upgrade.notify=false' };
|
|
895
|
+
}
|
|
896
|
+
const sessionId = (typeof o.sessionId === 'string' && o.sessionId.length > 0)
|
|
897
|
+
? o.sessionId
|
|
898
|
+
: `pid-${process.ppid || process.pid}`;
|
|
899
|
+
// Session-ID validation: alphanumeric + dash + underscore + dot. Stage-2 path-
|
|
900
|
+
// traversal: a malicious sessionId with `..` would still be safe (we only use
|
|
901
|
+
// it as a JSON key, never a path segment) but we strip control bytes anyway.
|
|
902
|
+
const safeSessionId = _safeForError(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
|
|
903
|
+
const fp = path.join(projectRoot, ADVISORY_REL_PATH);
|
|
904
|
+
let map = Object.create(null);
|
|
905
|
+
if (fs.existsSync(fp)) {
|
|
906
|
+
try {
|
|
907
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
908
|
+
const parsed = _safeJsonParse(raw);
|
|
909
|
+
if (parsed.ok) {
|
|
910
|
+
// Only keep entries from the last 24h. Stage-2 #10 lesson: malformed
|
|
911
|
+
// payloads degrade silently to "fresh advisory map".
|
|
912
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
913
|
+
const TTL = 24 * 60 * 60 * 1000;
|
|
914
|
+
for (const key of Object.keys(parsed.value)) {
|
|
915
|
+
const ts = parsed.value[key];
|
|
916
|
+
if (typeof ts === 'string') {
|
|
917
|
+
const tsNum = Date.parse(ts);
|
|
918
|
+
if (Number.isFinite(tsNum) && (now - tsNum) < TTL) {
|
|
919
|
+
map[key] = ts;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
} catch (_e) { /* treat as empty map */ }
|
|
925
|
+
}
|
|
926
|
+
if (Object.prototype.hasOwnProperty.call(map, safeSessionId)) {
|
|
927
|
+
return { shouldEmit: false, reason: 'already emitted this session' };
|
|
928
|
+
}
|
|
929
|
+
// Mark this session as emitted. Atomic write so a crash mid-write doesn't
|
|
930
|
+
// leave a partial file. Best-effort: if the write fails we still emit (the
|
|
931
|
+
// advisory is non-blocking and a missed throttle is preferable to silence).
|
|
932
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
933
|
+
map[safeSessionId] = new Date(now).toISOString();
|
|
934
|
+
try {
|
|
935
|
+
const content = JSON.stringify(map, null, 2) + '\n';
|
|
936
|
+
_atomicWriteFile(fp, content);
|
|
937
|
+
} catch (_e) {
|
|
938
|
+
// Non-blocking — emit anyway. The throttle is a best-effort niceness.
|
|
939
|
+
}
|
|
940
|
+
return { shouldEmit: true, reason: 'first emit this session' };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// @cap-todo(ac:F-084/AC-6) buildAdvisoryMessage formats the version-mismatch
|
|
944
|
+
// notice. Capped at 120 chars (Stage-2 #8 lesson: surface-limit). Both version
|
|
945
|
+
// strings are sanitized before interpolation (Stage-2 #2 lesson).
|
|
946
|
+
function buildAdvisoryMessage(installedVersion, markerVersion) {
|
|
947
|
+
const inst = _safeForError(installedVersion).slice(0, 16);
|
|
948
|
+
const mark = markerVersion === null ? 'unset' : _safeForError(markerVersion).slice(0, 16);
|
|
949
|
+
// Format: "[CAP] Run /cap:upgrade to migrate from X to Y." → kept short.
|
|
950
|
+
let msg;
|
|
951
|
+
if (markerVersion === null) {
|
|
952
|
+
msg = `[CAP] First run detected. Run /cap:upgrade to onboard CAP ${inst}.`;
|
|
953
|
+
} else {
|
|
954
|
+
msg = `[CAP] CAP ${inst} installed (last run: ${mark}). Run /cap:upgrade to migrate.`;
|
|
955
|
+
}
|
|
956
|
+
if (msg.length > 120) msg = msg.slice(0, 117) + '...';
|
|
957
|
+
return msg;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// @cap-todo(ac:F-084/AC-6) needsAdvisory checks if a version-mismatch warrants
|
|
961
|
+
// an advisory. True when installed != marker, or when marker is missing.
|
|
962
|
+
function needsAdvisory(installedVersion, markerVersion) {
|
|
963
|
+
if (typeof installedVersion !== 'string' || !_parseSemver(installedVersion)) return false;
|
|
964
|
+
if (markerVersion === null) return true; // first run
|
|
965
|
+
if (markerVersion === installedVersion) return false;
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// -------- Top-level orchestrator entry-point --------
|
|
970
|
+
|
|
971
|
+
// @cap-todo(ac:F-084/AC-1) summarizePlan formats a StagePlan[] for stdout.
|
|
972
|
+
// The markdown command spec consumes this for the dry-run preview UX.
|
|
973
|
+
// @cap-decision(F-084/iter1) Stage-2 #2 fix: AC-3 delta-summary now appears as a
|
|
974
|
+
// second indented line under each [RUN] stage (when a probe returned a non-null
|
|
975
|
+
// string). Skipped stages keep the single-line skip-reason format.
|
|
976
|
+
function summarizePlan(planResult) {
|
|
977
|
+
if (!planResult || !Array.isArray(planResult.plan)) return '';
|
|
978
|
+
const lines = [];
|
|
979
|
+
lines.push(`CAP installed: ${_safeForError(planResult.installedVersion)}`);
|
|
980
|
+
lines.push(`Last run: ${planResult.markerVersion ? _safeForError(planResult.markerVersion) : 'never (first run)'}`);
|
|
981
|
+
lines.push(`First run: ${planResult.firstRun ? 'yes' : 'no'}`);
|
|
982
|
+
lines.push(`Already current: ${planResult.alreadyCurrent ? 'yes' : 'no'}`);
|
|
983
|
+
lines.push('');
|
|
984
|
+
lines.push('Stages:');
|
|
985
|
+
for (const s of planResult.plan) {
|
|
986
|
+
const status = s.skip ? ' [SKIP]' : ' [RUN] ';
|
|
987
|
+
lines.push(`${status} ${s.name.padEnd(20)} ${_safeForError(s.reason)}`);
|
|
988
|
+
// Per-stage delta-summary (AC-3). Only emitted for [RUN] stages that produced a probe.
|
|
989
|
+
if (!s.skip && typeof s.delta === 'string' && s.delta.length > 0) {
|
|
990
|
+
lines.push(` delta: ${_safeForError(s.delta)}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return lines.join('\n');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
module.exports = {
|
|
997
|
+
// Constants
|
|
998
|
+
STAGES,
|
|
999
|
+
STAGE_NAMES,
|
|
1000
|
+
MARKER_REL_PATH,
|
|
1001
|
+
LOG_REL_PATH,
|
|
1002
|
+
ADVISORY_REL_PATH,
|
|
1003
|
+
MARKER_SCHEMA_VERSION,
|
|
1004
|
+
// Version
|
|
1005
|
+
getInstalledVersion,
|
|
1006
|
+
compareVersions,
|
|
1007
|
+
// Marker
|
|
1008
|
+
getMarkerVersion,
|
|
1009
|
+
writeMarker,
|
|
1010
|
+
// Log
|
|
1011
|
+
appendLog,
|
|
1012
|
+
readLog,
|
|
1013
|
+
// Plan + execute
|
|
1014
|
+
planMigrations,
|
|
1015
|
+
recordStageResult,
|
|
1016
|
+
summarizePlan,
|
|
1017
|
+
// Hook advisory
|
|
1018
|
+
shouldEmitAdvisory,
|
|
1019
|
+
buildAdvisoryMessage,
|
|
1020
|
+
needsAdvisory,
|
|
1021
|
+
// Internal exports for tests (Stage-2 #6 round-trip + #1 proto-pollution)
|
|
1022
|
+
_safeForError,
|
|
1023
|
+
_safeJsonParse,
|
|
1024
|
+
_parseSemver,
|
|
1025
|
+
// @cap-decision(F-084/iter1) Probe internals exposed for AC-3 delta-summary tests.
|
|
1026
|
+
_runProbe,
|
|
1027
|
+
DELTA_PROBES,
|
|
1028
|
+
};
|