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,1203 @@
|
|
|
1
|
+
// @cap-context CAP F-074 Enable Pattern Unlearn and Auto-Retract — closes the V5 self-learning loop.
|
|
2
|
+
// Applies F-071 patterns to running CAP behaviour, audits each apply, watches the
|
|
3
|
+
// post-apply fitness for 5 sessions, auto-flags patches whose Layer-1 override-rate
|
|
4
|
+
// worsens, and offers a clean unlearn path. F-073 (the review board, not yet built)
|
|
5
|
+
// will consume the retract list and trigger the unlearn from one click. This module
|
|
6
|
+
// ONLY exposes the list and the apply/unlearn primitives — it does NOT implement
|
|
7
|
+
// F-073's UI nor wire F-071 to read applied-state.json (both follow-ups, captured in
|
|
8
|
+
// @cap-todo tags below).
|
|
9
|
+
// @cap-decision(F-074/D1) Apply state location — Centralized `.cap/learning/applied-state.json` for
|
|
10
|
+
// L1 (parameters) and L2 (rules). L3 (prompt-template patches) makes real edits to
|
|
11
|
+
// agents/cap-*.md / commands/cap/*.md files; the originalText snapshot is stored
|
|
12
|
+
// inside the apply audit record at `.cap/learning/applied/P-NNN.json` so unlearn
|
|
13
|
+
// can reverse it deterministically. Locked by user direction.
|
|
14
|
+
// @cap-decision(F-074/D2) Apply consumers — `applied-state.json` is read by F-071 (parameter
|
|
15
|
+
// overrides like the heuristic threshold) and the eventual L2 rule consumer when a
|
|
16
|
+
// candidate matches an applied rule (suppress promotion). F-074 only WRITES the
|
|
17
|
+
// file; the consumer-side wiring is a follow-up captured in @cap-todo. The contract
|
|
18
|
+
// is documented at the top of readAppliedState() so a future PR can implement the
|
|
19
|
+
// read side without re-deriving the schema.
|
|
20
|
+
// @cap-decision(F-074/D3) Git commit safety — Stage ONLY CAP-managed files (.cap/learning/applied/...,
|
|
21
|
+
// applied-state.json, plus L3-edited agents/cap-*.md / commands/cap/*.md). Never
|
|
22
|
+
// `git add .` or `-A`. Run a normal `git commit` (NOT `--no-verify`) so user
|
|
23
|
+
// pre-commit hooks fire. On hook failure: write the audit record with
|
|
24
|
+
// applyState:'pending', leave staged files staged, return an error to the caller.
|
|
25
|
+
// The user can resolve manually (fix lint, re-commit) or call applyPattern again
|
|
26
|
+
// with `--retry` to retry the commit. CLAUDE.md forbids --no-verify and we honour it.
|
|
27
|
+
// @cap-decision(F-074/D4) L3 reverse-patch strategy — At apply time, store
|
|
28
|
+
// `{ originalText, patchedText, targetFile }` inside the audit record. At unlearn
|
|
29
|
+
// time, read the file's current content; if it === patchedText (no intermediate
|
|
30
|
+
// edits), restore originalText. If the file has drifted (current content !=
|
|
31
|
+
// patchedText), refuse with `{ unlearned: false, reason: 'l3-drift', commitHashToRevert }`
|
|
32
|
+
// so the user can `git revert` manually. Do NOT silently overwrite drifted L3 files.
|
|
33
|
+
// @cap-decision(F-074/D5) 5-session post-apply check (AC-5) — runs cold-path inside
|
|
34
|
+
// `runRetractCheck(projectRoot)`. For each applied pattern: count distinct override
|
|
35
|
+
// sessionIds since the apply commit (from F-070 corpus). When the post-apply session
|
|
36
|
+
// count crosses 5, compare current Layer-1 override-rate to fitnessSnapshot.layer1.value.
|
|
37
|
+
// If worse (current > snapshot for override-rate, since more overrides = pattern hurting
|
|
38
|
+
// more), append to retract-recommendations.jsonl. The .jsonl file is the single source
|
|
39
|
+
// of truth — `listRetractRecommended` reads it and de-dups by patternId (most-recent wins).
|
|
40
|
+
// @cap-decision(F-074/D6) Idempotency proof for AC-7 — Read `.cap/learning/unlearned/P-NNN.json` first.
|
|
41
|
+
// If exists → return early with `{ unlearned: false, reason: 'already-unlearned', priorRecord }`.
|
|
42
|
+
// No git operation, no second write. The unlearned-record-existence is the lock; we never
|
|
43
|
+
// rely on git history to detect a prior unlearn (would be brittle across rebases).
|
|
44
|
+
// @cap-decision(F-074/D7) Pending-apply retry semantics — When a commit fails (pre-commit hook
|
|
45
|
+
// non-zero exit), the apply audit is written with applyState:'pending' and
|
|
46
|
+
// commitHash:null, but the L3 file edit + applied-state mutation ARE persisted (the
|
|
47
|
+
// staged changes remain). On a `--retry` call, applyPattern detects the existing
|
|
48
|
+
// 'pending' audit, attempts only the commit step (re-stage + commit), and on success
|
|
49
|
+
// promotes the audit to applyState:'committed'. Without --retry, a second
|
|
50
|
+
// applyPattern call returns `{ applied: false, reason: 'already-applied' }` so a user
|
|
51
|
+
// cannot accidentally double-apply. PIN-1 below tracks this — the user should
|
|
52
|
+
// confirm the retry semantics before merge.
|
|
53
|
+
// @cap-constraint Zero external dependencies: node:fs + node:path + node:child_process (for git)
|
|
54
|
+
// only. We re-use cap-pattern-pipeline (listPatterns, getPattern), cap-fitness-score
|
|
55
|
+
// (recordApplySnapshot, getFitness), cap-learning-signals (getSignals). We never read
|
|
56
|
+
// overrides.jsonl / fitness JSONs / pattern JSONs directly — always through the
|
|
57
|
+
// module APIs.
|
|
58
|
+
// @cap-risk(F-074/AC-2) Every git invocation in this file carries this tag. A misfire (e.g. an
|
|
59
|
+
// accidental `git add .` or a commit that picks up unrelated files) would dirty the
|
|
60
|
+
// user's repo. The internal helper `gitStageAndCommit` is THE choke point — every
|
|
61
|
+
// path routes through it. Tests assert the staged file list is exactly what we asked
|
|
62
|
+
// for, never more.
|
|
63
|
+
// @cap-risk(F-074/AC-7) Idempotency guard at the top of unlearnPattern. A regression that fails to
|
|
64
|
+
// read .cap/learning/unlearned/<P-NNN>.json before mutating state would cause double
|
|
65
|
+
// commits. The adversarial test pins this with a count-of-commits assertion.
|
|
66
|
+
|
|
67
|
+
'use strict';
|
|
68
|
+
|
|
69
|
+
// @cap-feature(feature:F-074, primary:true) Enable Pattern Unlearn and Auto-Retract — apply audit,
|
|
70
|
+
// git-commit-per-apply, 5-session retract check,
|
|
71
|
+
// L1/L2/L3 reverse-patch with drift detection, idempotency.
|
|
72
|
+
|
|
73
|
+
const fs = require('node:fs');
|
|
74
|
+
const path = require('node:path');
|
|
75
|
+
const { spawnSync } = require('node:child_process');
|
|
76
|
+
|
|
77
|
+
const patternPipeline = require('./cap-pattern-pipeline.cjs');
|
|
78
|
+
const fitnessScore = require('./cap-fitness-score.cjs');
|
|
79
|
+
const learningSignals = require('./cap-learning-signals.cjs');
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------------------
|
|
82
|
+
// Constants — top-of-file so consumers (F-073, /cap:learn) and tests reference
|
|
83
|
+
// exactly one place. Mirrors cap-fitness-score.cjs / cap-pattern-pipeline.cjs.
|
|
84
|
+
// -----------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const CAP_DIR = '.cap';
|
|
87
|
+
const LEARNING_DIR = 'learning';
|
|
88
|
+
const APPLIED_DIR = 'applied';
|
|
89
|
+
const UNLEARNED_DIR = 'unlearned';
|
|
90
|
+
const APPLIED_STATE_FILE = 'applied-state.json';
|
|
91
|
+
const RETRACT_RECOMMENDATIONS_FILE = 'retract-recommendations.jsonl';
|
|
92
|
+
|
|
93
|
+
// AC-5: how many distinct override-corpus sessions must elapse between apply and the retract
|
|
94
|
+
// check before we trust the post-apply Layer-1 comparison. Centralised so tests can flex it via
|
|
95
|
+
// runRetractCheck options.window without redefining the rule.
|
|
96
|
+
const RETRACT_SESSION_THRESHOLD = 5;
|
|
97
|
+
|
|
98
|
+
// Pattern-id format mirror — duplicated here for the regex; canonical allocator lives in
|
|
99
|
+
// cap-pattern-pipeline.cjs.
|
|
100
|
+
const PATTERN_ID_RE = /^P-\d+$/;
|
|
101
|
+
|
|
102
|
+
// applied-state.json schema version — bump when the shape changes so a stale consumer can refuse
|
|
103
|
+
// to read newer state instead of mis-parsing it.
|
|
104
|
+
const APPLIED_STATE_VERSION = 1;
|
|
105
|
+
|
|
106
|
+
// L3-target whitelist — only files under these prefixes can be L3 patch targets. CLAUDE.md scopes
|
|
107
|
+
// CAP-managed L3 patches to agents/cap-*.md and commands/cap/*.md; we enforce that here so a
|
|
108
|
+
// hostile or buggy pattern cannot rewrite arbitrary user files.
|
|
109
|
+
// @cap-risk(F-074/AC-2) The L3 prefix gate is THE only thing standing between an attacker-crafted
|
|
110
|
+
// pattern and arbitrary file rewrites. Every L3 apply path routes through
|
|
111
|
+
// isAllowedL3Target(); the adversarial test verifies a pattern targeting
|
|
112
|
+
// `package.json` is rejected.
|
|
113
|
+
const L3_TARGET_PREFIXES = ['agents/', 'commands/cap/'];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @typedef {Object} AppliedAuditRecord
|
|
117
|
+
* @property {string} id - Mirrors patternId for back-compat with the F-071/F-072 record shape.
|
|
118
|
+
* @property {string} patternId - 'P-NNN'.
|
|
119
|
+
* @property {string} appliedAt - ISO timestamp.
|
|
120
|
+
* @property {'committed'|'pending'} applyState - 'pending' when the git commit failed (hook non-zero).
|
|
121
|
+
* @property {'L1'|'L2'|'L3'} level
|
|
122
|
+
* @property {string|null} featureRef - Feature ID this pattern targets, e.g. 'F-070'.
|
|
123
|
+
* @property {string|null} commitHash - Abbrev SHA of the apply commit; null when applyState='pending'.
|
|
124
|
+
* @property {string[]} targetFiles - Relative paths from projectRoot (the files we staged + committed).
|
|
125
|
+
* @property {object|null} fitnessSnapshot - The FitnessRecord captured at apply-time (F-072 SnapshotRecord).
|
|
126
|
+
* @property {object} beforeAfterDiff - Level-specific shape: L1 {key, from, to}; L2 {rule}; L3 {file, originalText, patchedText}.
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef {Object} UnlearnedAuditRecord
|
|
131
|
+
* @property {string} id
|
|
132
|
+
* @property {string} patternId
|
|
133
|
+
* @property {string} unlearnedAt - ISO timestamp.
|
|
134
|
+
* @property {'manual'|'auto-retract'} reason
|
|
135
|
+
* @property {string|null} commitHash - SHA of the unlearn commit; null on git failure.
|
|
136
|
+
* @property {string|null} appliedCommitHash - SHA of the prior apply commit, for traceability.
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} AppliedState
|
|
141
|
+
* @property {number} version
|
|
142
|
+
* @property {Object<string, *>} l1 - Parameter overrides keyed by `{F-NNN}/{KEY}` strings.
|
|
143
|
+
* @property {Array<{patternId:string, rule:object, appliedAt:string}>} l2
|
|
144
|
+
* @property {Array<{patternId:string, file:string, appliedAt:string}>} l3
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
// -----------------------------------------------------------------------------
|
|
148
|
+
// Internal helpers — directory + IO
|
|
149
|
+
// -----------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function ensureDir(dir) {
|
|
152
|
+
try {
|
|
153
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
154
|
+
} catch (_e) {
|
|
155
|
+
// Public boundary callers swallow; the next write surfaces persistent IO problems.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function learningRoot(projectRoot) {
|
|
160
|
+
return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function appliedDir(projectRoot) {
|
|
164
|
+
return path.join(learningRoot(projectRoot), APPLIED_DIR);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function unlearnedDir(projectRoot) {
|
|
168
|
+
return path.join(learningRoot(projectRoot), UNLEARNED_DIR);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appliedStateFilePath(projectRoot) {
|
|
172
|
+
return path.join(learningRoot(projectRoot), APPLIED_STATE_FILE);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function retractRecommendationsPath(projectRoot) {
|
|
176
|
+
return path.join(learningRoot(projectRoot), RETRACT_RECOMMENDATIONS_FILE);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function appliedAuditPath(projectRoot, patternId) {
|
|
180
|
+
return path.join(appliedDir(projectRoot), `${patternId}.json`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unlearnedAuditPath(projectRoot, patternId) {
|
|
184
|
+
return path.join(unlearnedDir(projectRoot), `${patternId}.json`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Validate a P-NNN id. Every public boundary routes through this gate so a hostile or
|
|
189
|
+
* malformed id can never become a path. Mirrors cap-fitness-score.cjs#isValidPatternId.
|
|
190
|
+
* @param {any} id
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
function isValidPatternId(id) {
|
|
194
|
+
return typeof id === 'string' && PATTERN_ID_RE.test(id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Look up the persisted PatternRecord with the given id. Returns null when missing or
|
|
199
|
+
* unreadable. Never throws.
|
|
200
|
+
* @param {string} projectRoot
|
|
201
|
+
* @param {string} patternId
|
|
202
|
+
* @returns {object|null}
|
|
203
|
+
*/
|
|
204
|
+
function findPattern(projectRoot, patternId) {
|
|
205
|
+
try {
|
|
206
|
+
const all = patternPipeline.listPatterns(projectRoot);
|
|
207
|
+
if (!Array.isArray(all)) return null;
|
|
208
|
+
for (const p of all) {
|
|
209
|
+
if (p && p.id === patternId) return p;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
} catch (_e) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read a JSON file or return null. Never throws.
|
|
219
|
+
* @param {string} fp
|
|
220
|
+
* @returns {object|null}
|
|
221
|
+
*/
|
|
222
|
+
function readJson(fp) {
|
|
223
|
+
try {
|
|
224
|
+
if (!fs.existsSync(fp)) return null;
|
|
225
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
226
|
+
const parsed = JSON.parse(raw);
|
|
227
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
228
|
+
return parsed;
|
|
229
|
+
} catch (_e) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Write a JSON file with trailing newline. Returns true on success.
|
|
236
|
+
* @param {string} fp
|
|
237
|
+
* @param {object} data
|
|
238
|
+
* @returns {boolean}
|
|
239
|
+
*/
|
|
240
|
+
// @cap-decision(F-074/D8) Atomic write-temp-then-rename for ALL JSON writes in this module.
|
|
241
|
+
// applied-state.json, applied/<P>.json, unlearned/<P>.json — interruption between
|
|
242
|
+
// truncate and flush (Ctrl-C, OOM, hardware fault) would otherwise leave a zero-byte
|
|
243
|
+
// or partial-JSON file. The next read would return null, F-071 would silently revert
|
|
244
|
+
// every prior L1/L2 override. POSIX rename(2) is atomic; NTFS rename is good-enough.
|
|
245
|
+
// Fixed pre-ship per Stage-2 review.
|
|
246
|
+
function writeJson(fp, data) {
|
|
247
|
+
try {
|
|
248
|
+
ensureDir(path.dirname(fp));
|
|
249
|
+
const tmp = fp + '.tmp';
|
|
250
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
251
|
+
fs.renameSync(tmp, fp);
|
|
252
|
+
return true;
|
|
253
|
+
} catch (_e) {
|
|
254
|
+
// Best-effort cleanup — leave no .tmp orphan on failure.
|
|
255
|
+
try { fs.unlinkSync(fp + '.tmp'); } catch (_e2) { /* ignore */ }
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read the SESSION.json sessionId, if any. Used to attribute apply/unlearn audit records
|
|
262
|
+
* to a session. Falls back to null silently. Never throws.
|
|
263
|
+
* @param {string} projectRoot
|
|
264
|
+
* @returns {string|null}
|
|
265
|
+
*/
|
|
266
|
+
function currentSessionId(projectRoot) {
|
|
267
|
+
try {
|
|
268
|
+
const fp = path.join(projectRoot, CAP_DIR, 'SESSION.json');
|
|
269
|
+
if (!fs.existsSync(fp)) return null;
|
|
270
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
271
|
+
const parsed = JSON.parse(raw);
|
|
272
|
+
if (parsed && typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
|
|
273
|
+
return parsed.sessionId;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
} catch (_e) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// -----------------------------------------------------------------------------
|
|
282
|
+
// Git helpers — single choke point so the safety contract (D3) lives in ONE place.
|
|
283
|
+
// -----------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Run a git command in the project root. Returns { stdout, stderr, status }.
|
|
287
|
+
* Never throws — callers inspect status for failure.
|
|
288
|
+
* @param {string} projectRoot
|
|
289
|
+
* @param {string[]} args
|
|
290
|
+
* @returns {{stdout:string, stderr:string, status:number|null}}
|
|
291
|
+
*/
|
|
292
|
+
function git(projectRoot, args) {
|
|
293
|
+
// @cap-risk(F-074/AC-2) Every git command in this file routes through here. We use spawnSync
|
|
294
|
+
// (not execSync) so an untrusted argv is passed as an array — no shell
|
|
295
|
+
// interpolation, no opportunity for `; rm -rf` injection through a
|
|
296
|
+
// hostile pattern field.
|
|
297
|
+
const result = spawnSync('git', args, {
|
|
298
|
+
cwd: projectRoot,
|
|
299
|
+
encoding: 'utf8',
|
|
300
|
+
// Inherit user env so global git config (user.name/user.email) and pre-commit-hook PATH work.
|
|
301
|
+
env: process.env,
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
305
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
306
|
+
status: typeof result.status === 'number' ? result.status : null,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Stage SPECIFIC files and run a normal `git commit`. CLAUDE.md forbids `--no-verify`; user
|
|
312
|
+
* pre-commit hooks must fire. Returns the commit hash on success or an error reason on failure.
|
|
313
|
+
*
|
|
314
|
+
* @cap-risk(F-074/AC-2) `files` is the closed set we stage. Callers MUST pass exact paths;
|
|
315
|
+
* passing a directory or a glob would silently expand to a wider stage.
|
|
316
|
+
* The internal `git add --` ensures the args after `--` are treated as
|
|
317
|
+
* file paths even when one starts with `-`.
|
|
318
|
+
*
|
|
319
|
+
* @cap-decision(F-074/D3) On hook failure (status !== 0 from `git commit`), we DO NOT unstage.
|
|
320
|
+
* The user fixes the hook issue manually (lint, format) and either
|
|
321
|
+
* re-runs `git commit` themselves or invokes applyPattern with `--retry`.
|
|
322
|
+
* Unstaging would silently lose the L3 file edit + the audit record write.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} projectRoot
|
|
325
|
+
* @param {string[]} files - Relative paths from projectRoot. NEVER use '.' or '-A'.
|
|
326
|
+
* @param {string} message - Commit message; passed verbatim to `git commit -m`.
|
|
327
|
+
* @returns {{success:boolean, commitHash?:string, error?:string, stage?:string}}
|
|
328
|
+
* stage: 'add' | 'commit' — which step failed.
|
|
329
|
+
*/
|
|
330
|
+
function gitStageAndCommit(projectRoot, files, message) {
|
|
331
|
+
// Defensive — refuse to operate on a non-list or empty list. A bug that calls with [] would
|
|
332
|
+
// otherwise produce an empty `git add --` (no-op) followed by `git commit` of whatever was
|
|
333
|
+
// already staged, leaking state across calls.
|
|
334
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
335
|
+
return { success: false, error: 'no files to stage', stage: 'add' };
|
|
336
|
+
}
|
|
337
|
+
for (const f of files) {
|
|
338
|
+
if (typeof f !== 'string' || f.length === 0) {
|
|
339
|
+
return { success: false, error: 'invalid file path in stage list', stage: 'add' };
|
|
340
|
+
}
|
|
341
|
+
// Refuse the wildcard catch-alls explicitly. CLAUDE.md forbids them.
|
|
342
|
+
if (f === '.' || f === '-A' || f === '-a' || f === '*') {
|
|
343
|
+
return { success: false, error: `wildcard stage refused: ${f}`, stage: 'add' };
|
|
344
|
+
}
|
|
345
|
+
// @cap-decision(F-074/D9) Path-traversal defense-in-depth. Git's pathspec resolution
|
|
346
|
+
// already refuses paths outside the repo, but the retry path consumes the
|
|
347
|
+
// on-disk audit's targetFiles verbatim — a forged audit could pass an
|
|
348
|
+
// absolute path or a `..`-climb string. Refuse at the F-074 boundary
|
|
349
|
+
// instead of relying on git's behaviour.
|
|
350
|
+
if (path.isAbsolute(f) || f.startsWith('../') || f.includes('/../') || f === '..') {
|
|
351
|
+
return { success: false, error: `path-traversal refused: ${f}`, stage: 'add' };
|
|
352
|
+
}
|
|
353
|
+
const normalized = path.posix.normalize(f.replace(/\\/g, '/'));
|
|
354
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
355
|
+
return { success: false, error: `path-traversal refused (post-normalize): ${f}`, stage: 'add' };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Stage the closed set. The `--` sentinel ensures every subsequent token is a path even if
|
|
360
|
+
// one starts with `-`.
|
|
361
|
+
const addArgs = ['add', '--', ...files];
|
|
362
|
+
const addResult = git(projectRoot, addArgs);
|
|
363
|
+
if (addResult.status !== 0) {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
error: `git add failed: ${addResult.stderr.trim() || `status ${addResult.status}`}`,
|
|
367
|
+
stage: 'add',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// @cap-risk(F-074/AC-2) Plain `git commit` — pre-commit hooks WILL fire. CLAUDE.md forbids
|
|
372
|
+
// --no-verify; we honour it. On hook failure the staged files remain
|
|
373
|
+
// staged so the user can resolve manually.
|
|
374
|
+
// @cap-decision(F-074/D3) Pass the message via -m as a single argv (no shell interpolation).
|
|
375
|
+
// Multi-line messages are flattened by callers; we don't need a HEREDOC
|
|
376
|
+
// in this codepath.
|
|
377
|
+
const commitArgs = ['commit', '-m', message];
|
|
378
|
+
const commitResult = git(projectRoot, commitArgs);
|
|
379
|
+
if (commitResult.status !== 0) {
|
|
380
|
+
return {
|
|
381
|
+
success: false,
|
|
382
|
+
error: `git commit failed: ${commitResult.stderr.trim() || commitResult.stdout.trim() || `status ${commitResult.status}`}`,
|
|
383
|
+
stage: 'commit',
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Read the abbreviated SHA of the new HEAD. Using --short for stable abbrev length and
|
|
388
|
+
// `-1` so we get exactly the commit we just created.
|
|
389
|
+
const sha = git(projectRoot, ['rev-parse', '--short', 'HEAD']);
|
|
390
|
+
if (sha.status !== 0) {
|
|
391
|
+
// Commit succeeded but we couldn't read the hash — degrade gracefully with null hash so
|
|
392
|
+
// the audit record still lands; the user can recover the SHA from `git log` manually.
|
|
393
|
+
return { success: true, commitHash: null };
|
|
394
|
+
}
|
|
395
|
+
return { success: true, commitHash: sha.stdout.trim() };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// -----------------------------------------------------------------------------
|
|
399
|
+
// L3 target whitelist
|
|
400
|
+
// -----------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Return true iff the relative path is allowed as an L3 patch target. CLAUDE.md scopes L3
|
|
404
|
+
* to agents/ and commands/cap/ — patterns targeting anything else are rejected.
|
|
405
|
+
*
|
|
406
|
+
* @cap-risk(F-074/AC-2) THE prefix gate. A regression here would let a hostile pattern rewrite
|
|
407
|
+
* arbitrary repo files. Adversarial test verifies a pattern targeting
|
|
408
|
+
* `package.json` (or `../etc/passwd`) is rejected.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} relPath
|
|
411
|
+
* @returns {boolean}
|
|
412
|
+
*/
|
|
413
|
+
function isAllowedL3Target(relPath) {
|
|
414
|
+
if (typeof relPath !== 'string' || relPath.length === 0) return false;
|
|
415
|
+
// Reject path-traversal explicitly. A path like `agents/../../../etc/passwd` would otherwise
|
|
416
|
+
// pass the prefix check; we use path.normalize and refuse anything that climbs out.
|
|
417
|
+
const normalized = path.posix.normalize(relPath.replace(/\\/g, '/'));
|
|
418
|
+
if (normalized.startsWith('..') || normalized.startsWith('/')) return false;
|
|
419
|
+
for (const prefix of L3_TARGET_PREFIXES) {
|
|
420
|
+
// @cap-decision(F-074/D10) Require a non-empty segment after the prefix. A bare `agents/` or
|
|
421
|
+
// `commands/cap/` (no filename) would otherwise pass; the file read would
|
|
422
|
+
// then catch EISDIR, but it's cleaner to refuse at the gate.
|
|
423
|
+
if (normalized.startsWith(prefix) && normalized.length > prefix.length) return true;
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// -----------------------------------------------------------------------------
|
|
429
|
+
// applied-state.json read/write
|
|
430
|
+
// -----------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Read the centralised applied-state file. Returns the default empty state when the file is
|
|
434
|
+
* missing or malformed. Never throws.
|
|
435
|
+
*
|
|
436
|
+
* @cap-decision(F-074/D2) F-071 / future L2 consumer reads this file to honour applied
|
|
437
|
+
* parameters / rules. F-074 only writes it; the consumer wiring is a
|
|
438
|
+
* follow-up captured in @cap-todo. Schema:
|
|
439
|
+
* {
|
|
440
|
+
* version: 1,
|
|
441
|
+
* l1: { '<featureId>/<KEY>': value, ... },
|
|
442
|
+
* l2: [ { patternId, rule, appliedAt }, ... ],
|
|
443
|
+
* l3: [ { patternId, file, appliedAt }, ... ]
|
|
444
|
+
* }
|
|
445
|
+
*
|
|
446
|
+
* @cap-todo(ac:F-074/AC-1) F-071 (cap-pattern-pipeline) shall read appliedState.l1[`F-071/THRESHOLD_OVERRIDE_COUNT`]
|
|
447
|
+
* and use it as the override-threshold override before falling back to the
|
|
448
|
+
* THRESHOLD_OVERRIDE_COUNT constant. This wiring is OUT OF SCOPE for F-074;
|
|
449
|
+
* a follow-up PR will add it without changing F-074's API.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} projectRoot
|
|
452
|
+
* @returns {AppliedState}
|
|
453
|
+
*/
|
|
454
|
+
function readAppliedState(projectRoot) {
|
|
455
|
+
const fp = appliedStateFilePath(projectRoot);
|
|
456
|
+
const parsed = readJson(fp);
|
|
457
|
+
if (!parsed) {
|
|
458
|
+
return { version: APPLIED_STATE_VERSION, l1: {}, l2: [], l3: [] };
|
|
459
|
+
}
|
|
460
|
+
// Defensive shape normalisation — a hand-edited file with a missing field shouldn't crash callers.
|
|
461
|
+
return {
|
|
462
|
+
version: typeof parsed.version === 'number' ? parsed.version : APPLIED_STATE_VERSION,
|
|
463
|
+
l1: (parsed.l1 && typeof parsed.l1 === 'object' && !Array.isArray(parsed.l1)) ? parsed.l1 : {},
|
|
464
|
+
l2: Array.isArray(parsed.l2) ? parsed.l2 : [],
|
|
465
|
+
l3: Array.isArray(parsed.l3) ? parsed.l3 : [],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Write the centralised applied-state file. Returns true on success. Test helper +
|
|
471
|
+
* called internally by applyPattern / unlearnPattern.
|
|
472
|
+
* @param {string} projectRoot
|
|
473
|
+
* @param {AppliedState} state
|
|
474
|
+
* @returns {boolean}
|
|
475
|
+
*/
|
|
476
|
+
function writeAppliedState(projectRoot, state) {
|
|
477
|
+
if (!state || typeof state !== 'object') return false;
|
|
478
|
+
ensureDir(learningRoot(projectRoot));
|
|
479
|
+
return writeJson(appliedStateFilePath(projectRoot), {
|
|
480
|
+
version: typeof state.version === 'number' ? state.version : APPLIED_STATE_VERSION,
|
|
481
|
+
l1: (state.l1 && typeof state.l1 === 'object' && !Array.isArray(state.l1)) ? state.l1 : {},
|
|
482
|
+
l2: Array.isArray(state.l2) ? state.l2 : [],
|
|
483
|
+
l3: Array.isArray(state.l3) ? state.l3 : [],
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// -----------------------------------------------------------------------------
|
|
488
|
+
// Level-specific apply/reverse helpers
|
|
489
|
+
// -----------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Apply an L1 (parameter) pattern. Mutates the in-memory state and persists it. Returns
|
|
493
|
+
* the beforeAfterDiff that lands in the audit record.
|
|
494
|
+
* @param {AppliedState} state
|
|
495
|
+
* @param {object} pattern
|
|
496
|
+
* @returns {{key:string, from:any, to:any}}
|
|
497
|
+
*/
|
|
498
|
+
function applyL1(state, pattern) {
|
|
499
|
+
const sug = pattern && pattern.suggestion;
|
|
500
|
+
// F-071's L1 suggestion shape: { kind: 'L1', target: '<featureId>/<KEY>', from: <prior>, to: <new>, rationale }.
|
|
501
|
+
const key = (sug && typeof sug.target === 'string') ? sug.target : `${pattern.id}/value`;
|
|
502
|
+
// @cap-decision(F-074/D1) Record whether the key was already set in applied-state. On unlearn we
|
|
503
|
+
// restore the prior value if `hadPrior` is true, otherwise we delete the
|
|
504
|
+
// key so the post-unlearn state shape matches pre-apply byte-for-byte.
|
|
505
|
+
// Recording the boolean explicitly avoids the "null is a real value vs.
|
|
506
|
+
// null means unset" ambiguity that bit me on the first iteration.
|
|
507
|
+
const hadPrior = Object.prototype.hasOwnProperty.call(state.l1, key);
|
|
508
|
+
const from = hadPrior ? state.l1[key] : null;
|
|
509
|
+
const to = sug && Object.prototype.hasOwnProperty.call(sug, 'to') ? sug.to : null;
|
|
510
|
+
state.l1[key] = to;
|
|
511
|
+
return { key, hadPrior, from, to };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Reverse an L1 apply. The audit's diff carries `{ key, hadPrior, from, to }`; if hadPrior we
|
|
516
|
+
* restore `from`, else we delete the key. Falls back to the legacy 2026-05 shape (no hadPrior
|
|
517
|
+
* field) by treating `from === null` as "unset" — keeps a future schema migration painless.
|
|
518
|
+
* @param {AppliedState} state
|
|
519
|
+
* @param {{key:string, hadPrior?:boolean, from:any, to:any}} diff
|
|
520
|
+
*/
|
|
521
|
+
function reverseL1(state, diff) {
|
|
522
|
+
if (!diff || typeof diff.key !== 'string') return;
|
|
523
|
+
// Strict path: hadPrior is the authoritative signal.
|
|
524
|
+
if (diff.hadPrior === true) {
|
|
525
|
+
state.l1[diff.key] = diff.from;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (diff.hadPrior === false) {
|
|
529
|
+
delete state.l1[diff.key];
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Legacy path (no hadPrior recorded): treat null/undefined `from` as "unset".
|
|
533
|
+
if (diff.from === null || diff.from === undefined) {
|
|
534
|
+
delete state.l1[diff.key];
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
state.l1[diff.key] = diff.from;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Apply an L2 (rule) pattern. Append the rule object to state.l2 with the patternId and ts.
|
|
542
|
+
* @param {AppliedState} state
|
|
543
|
+
* @param {object} pattern
|
|
544
|
+
* @param {string} appliedAt
|
|
545
|
+
* @returns {{rule:object}}
|
|
546
|
+
*/
|
|
547
|
+
function applyL2(state, pattern, appliedAt) {
|
|
548
|
+
const rule = (pattern && pattern.suggestion) || { kind: 'L2' };
|
|
549
|
+
state.l2.push({ patternId: pattern.id, rule, appliedAt });
|
|
550
|
+
return { rule };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Reverse an L2 apply. Remove every entry whose patternId matches.
|
|
555
|
+
* @param {AppliedState} state
|
|
556
|
+
* @param {string} patternId
|
|
557
|
+
*/
|
|
558
|
+
function reverseL2(state, patternId) {
|
|
559
|
+
state.l2 = state.l2.filter((entry) => entry && entry.patternId !== patternId);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Apply an L3 (prompt-template patch) pattern. Reads the target file, snapshots originalText,
|
|
564
|
+
* writes patchedText, returns the diff for the audit record.
|
|
565
|
+
*
|
|
566
|
+
* @cap-decision(F-074/D4) We capture BOTH original and patched text in the audit; unlearn uses
|
|
567
|
+
* the comparison `currentContent === patchedText` to detect drift. If the
|
|
568
|
+
* file has been edited between apply and unlearn, we refuse to revert.
|
|
569
|
+
*
|
|
570
|
+
* @param {string} projectRoot
|
|
571
|
+
* @param {object} pattern
|
|
572
|
+
* @returns {{file:string, originalText:string, patchedText:string}|{error:string}}
|
|
573
|
+
*/
|
|
574
|
+
function applyL3(projectRoot, pattern) {
|
|
575
|
+
const sug = pattern && pattern.suggestion;
|
|
576
|
+
if (!sug || typeof sug !== 'object') {
|
|
577
|
+
return { error: 'l3-suggestion-missing' };
|
|
578
|
+
}
|
|
579
|
+
const file = typeof sug.file === 'string' ? sug.file : (typeof sug.target === 'string' ? sug.target : null);
|
|
580
|
+
if (!file) return { error: 'l3-target-missing' };
|
|
581
|
+
if (!isAllowedL3Target(file)) return { error: 'l3-target-not-allowed' };
|
|
582
|
+
|
|
583
|
+
const abs = path.join(projectRoot, file);
|
|
584
|
+
let originalText;
|
|
585
|
+
try {
|
|
586
|
+
if (!fs.existsSync(abs)) return { error: 'l3-target-missing' };
|
|
587
|
+
originalText = fs.readFileSync(abs, 'utf8');
|
|
588
|
+
} catch (_e) {
|
|
589
|
+
return { error: 'l3-read-failed' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// The patched text is supplied by the pattern. F-071's LLM-stage L3 suggestion shape carries
|
|
593
|
+
// either `patchedText` (full replacement) or `patch` (a future diff format we don't support
|
|
594
|
+
// yet). Strict path: require patchedText for now; reject otherwise so we don't half-apply.
|
|
595
|
+
const patchedText = typeof sug.patchedText === 'string' ? sug.patchedText : null;
|
|
596
|
+
if (patchedText === null) return { error: 'l3-patched-text-missing' };
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
fs.writeFileSync(abs, patchedText, 'utf8');
|
|
600
|
+
} catch (_e) {
|
|
601
|
+
return { error: 'l3-write-failed' };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return { file, originalText, patchedText };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Reverse an L3 apply. Reads the current file content, asserts it === patchedText, then
|
|
609
|
+
* restores originalText. If drift is detected (current !== patchedText), refuses.
|
|
610
|
+
*
|
|
611
|
+
* @cap-decision(F-074/D4) Drift detection is byte-exact equality. A trailing-newline change or a
|
|
612
|
+
* CRLF↔LF flip will trigger drift; that's intentional — we'd rather refuse
|
|
613
|
+
* and let the user resolve via `git revert <apply-hash>` than silently
|
|
614
|
+
* clobber a downstream edit.
|
|
615
|
+
*
|
|
616
|
+
* @param {string} projectRoot
|
|
617
|
+
* @param {{file:string, originalText:string, patchedText:string}} diff
|
|
618
|
+
* @returns {{success:true, file:string} | {success:false, reason:'l3-drift'|'l3-target-missing'|'l3-read-failed'|'l3-write-failed'|'l3-target-not-allowed'}}
|
|
619
|
+
*/
|
|
620
|
+
function reverseL3(projectRoot, diff) {
|
|
621
|
+
if (!diff || typeof diff.file !== 'string') {
|
|
622
|
+
return { success: false, reason: 'l3-target-missing' };
|
|
623
|
+
}
|
|
624
|
+
if (!isAllowedL3Target(diff.file)) {
|
|
625
|
+
// Defensive: a malformed audit could carry an out-of-scope file. Refuse rather than write.
|
|
626
|
+
return { success: false, reason: 'l3-target-not-allowed' };
|
|
627
|
+
}
|
|
628
|
+
const abs = path.join(projectRoot, diff.file);
|
|
629
|
+
let current;
|
|
630
|
+
try {
|
|
631
|
+
if (!fs.existsSync(abs)) return { success: false, reason: 'l3-target-missing' };
|
|
632
|
+
current = fs.readFileSync(abs, 'utf8');
|
|
633
|
+
} catch (_e) {
|
|
634
|
+
return { success: false, reason: 'l3-read-failed' };
|
|
635
|
+
}
|
|
636
|
+
if (current !== diff.patchedText) {
|
|
637
|
+
return { success: false, reason: 'l3-drift' };
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
fs.writeFileSync(abs, diff.originalText, 'utf8');
|
|
641
|
+
} catch (_e) {
|
|
642
|
+
return { success: false, reason: 'l3-write-failed' };
|
|
643
|
+
}
|
|
644
|
+
return { success: true, file: diff.file };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// -----------------------------------------------------------------------------
|
|
648
|
+
// Public API — applyPattern (AC-1, AC-2)
|
|
649
|
+
// -----------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
// @cap-todo(ac:F-074/AC-1) Audit record per apply at .cap/learning/applied/P-NNN.json with
|
|
652
|
+
// {patternId, appliedAt, level, targetFiles, featureRef, fitnessSnapshot,
|
|
653
|
+
// beforeAfterDiff?, applyState}.
|
|
654
|
+
// @cap-todo(ac:F-074/AC-2) Each apply creates `learn: apply P-NNN (F-XXX)` git commit.
|
|
655
|
+
/**
|
|
656
|
+
* Apply a pattern: write the audit record, mutate applied-state.json (or the L3 file), capture
|
|
657
|
+
* a fitness snapshot, and create a git commit. Returns success or a structured failure reason.
|
|
658
|
+
*
|
|
659
|
+
* @cap-decision(F-074/D7) Already-applied detection routes through the audit-record-existence
|
|
660
|
+
* check (NOT git history). When `options.retry === true` AND the existing
|
|
661
|
+
* audit's applyState is 'pending', we retry only the commit step.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} projectRoot
|
|
664
|
+
* @param {string} patternId
|
|
665
|
+
* @param {Object} [options]
|
|
666
|
+
* @param {Date|string} [options.now] - Override the persisted timestamps (mostly for tests).
|
|
667
|
+
* @param {boolean} [options.retry] - Retry a prior pending commit (do NOT re-mutate state).
|
|
668
|
+
* @param {'manual'|'auto'} [options.trigger] - Audit flavour (default 'manual').
|
|
669
|
+
* @returns {{applied:true, commitHash:string|null, audit:AppliedAuditRecord}
|
|
670
|
+
* | {applied:false, reason:'pattern-not-found'|'l3-target-missing'|'l3-target-not-allowed'|'l3-patched-text-missing'|'l3-suggestion-missing'|'l3-read-failed'|'l3-write-failed'|'pending-hook-fail'|'already-applied'|'invalid-pattern-id'|'invalid-project-root'|'unsupported-level', error?:string, audit?:AppliedAuditRecord}}
|
|
671
|
+
*/
|
|
672
|
+
function applyPattern(projectRoot, patternId, options) {
|
|
673
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
674
|
+
return { applied: false, reason: 'invalid-project-root' };
|
|
675
|
+
}
|
|
676
|
+
if (!isValidPatternId(patternId)) {
|
|
677
|
+
return { applied: false, reason: 'invalid-pattern-id' };
|
|
678
|
+
}
|
|
679
|
+
const opts = options || {};
|
|
680
|
+
const retry = opts.retry === true;
|
|
681
|
+
const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
682
|
+
|
|
683
|
+
// Idempotency / retry gate — read the prior audit record (if any) BEFORE mutating state.
|
|
684
|
+
// @cap-risk(F-074/AC-7) Without this gate, a second applyPattern call would double-apply and
|
|
685
|
+
// create a duplicate commit. The audit-record-existence is the lock.
|
|
686
|
+
const priorAudit = readJson(appliedAuditPath(projectRoot, patternId));
|
|
687
|
+
if (priorAudit && !retry) {
|
|
688
|
+
return { applied: false, reason: 'already-applied', audit: priorAudit };
|
|
689
|
+
}
|
|
690
|
+
if (priorAudit && retry && priorAudit.applyState !== 'pending') {
|
|
691
|
+
// Retry on a committed audit is a no-op — nothing to retry.
|
|
692
|
+
return { applied: false, reason: 'already-applied', audit: priorAudit };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const pattern = findPattern(projectRoot, patternId);
|
|
696
|
+
if (!pattern) {
|
|
697
|
+
return { applied: false, reason: 'pattern-not-found' };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Retry path — skip the state-mutation step, just re-stage and re-commit using the existing audit.
|
|
701
|
+
if (retry && priorAudit && priorAudit.applyState === 'pending') {
|
|
702
|
+
return retryApplyCommit(projectRoot, priorAudit);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const level = pattern.level;
|
|
706
|
+
if (level !== 'L1' && level !== 'L2' && level !== 'L3') {
|
|
707
|
+
return { applied: false, reason: 'unsupported-level' };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// -- State mutation --
|
|
711
|
+
const state = readAppliedState(projectRoot);
|
|
712
|
+
let beforeAfterDiff;
|
|
713
|
+
/** @type {string[]} */
|
|
714
|
+
const targetFiles = [];
|
|
715
|
+
|
|
716
|
+
if (level === 'L1') {
|
|
717
|
+
const diff = applyL1(state, pattern);
|
|
718
|
+
beforeAfterDiff = { L1: diff };
|
|
719
|
+
} else if (level === 'L2') {
|
|
720
|
+
const diff = applyL2(state, pattern, nowIso);
|
|
721
|
+
beforeAfterDiff = { L2: diff };
|
|
722
|
+
} else {
|
|
723
|
+
// L3
|
|
724
|
+
const diff = applyL3(projectRoot, pattern);
|
|
725
|
+
if (diff.error) {
|
|
726
|
+
// L3 apply failed before any state mutation — return the error reason verbatim.
|
|
727
|
+
return { applied: false, reason: diff.error };
|
|
728
|
+
}
|
|
729
|
+
beforeAfterDiff = { L3: diff };
|
|
730
|
+
state.l3.push({ patternId: pattern.id, file: diff.file, appliedAt: nowIso });
|
|
731
|
+
targetFiles.push(diff.file);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Persist applied-state.json for L1 / L2 / L3 (L3 also needs the rule entry).
|
|
735
|
+
writeAppliedState(projectRoot, state);
|
|
736
|
+
|
|
737
|
+
// -- Fitness snapshot --
|
|
738
|
+
// F-072 takes the snapshot append-only into <P-NNN>.snapshots.jsonl and returns the SnapshotRecord.
|
|
739
|
+
// We embed that record in the audit so the AC-5 retract check can compare without re-reading.
|
|
740
|
+
let fitnessSnapshot = null;
|
|
741
|
+
try {
|
|
742
|
+
fitnessSnapshot = fitnessScore.recordApplySnapshot(projectRoot, patternId, { now: nowIso });
|
|
743
|
+
} catch (_e) {
|
|
744
|
+
fitnessSnapshot = null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// -- Audit record --
|
|
748
|
+
const featureRef = (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef))
|
|
749
|
+
? pattern.featureRef
|
|
750
|
+
: null;
|
|
751
|
+
|
|
752
|
+
/** @type {AppliedAuditRecord} */
|
|
753
|
+
const audit = {
|
|
754
|
+
id: patternId,
|
|
755
|
+
patternId,
|
|
756
|
+
appliedAt: nowIso,
|
|
757
|
+
applyState: 'pending', // upgraded to 'committed' once git commit succeeds
|
|
758
|
+
level,
|
|
759
|
+
featureRef,
|
|
760
|
+
commitHash: null,
|
|
761
|
+
targetFiles: [
|
|
762
|
+
// Always include the audit + applied-state files; L3 adds the patched file too.
|
|
763
|
+
path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_DIR, `${patternId}.json`),
|
|
764
|
+
path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_STATE_FILE),
|
|
765
|
+
...targetFiles,
|
|
766
|
+
],
|
|
767
|
+
fitnessSnapshot,
|
|
768
|
+
beforeAfterDiff,
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// Persist the audit BEFORE the commit so a hook failure leaves an applyState:'pending' record on disk.
|
|
772
|
+
writeJson(appliedAuditPath(projectRoot, patternId), audit);
|
|
773
|
+
|
|
774
|
+
// -- Git commit --
|
|
775
|
+
// @cap-risk(F-074/AC-2) Commit message format is contractual — F-073 will parse it for the review board.
|
|
776
|
+
// Format: `learn: apply P-NNN (F-XXX)` or `learn: apply P-NNN` when no featureRef.
|
|
777
|
+
const commitMsg = featureRef
|
|
778
|
+
? `learn: apply ${patternId} (${featureRef})`
|
|
779
|
+
: `learn: apply ${patternId}`;
|
|
780
|
+
const commitResult = gitStageAndCommit(projectRoot, audit.targetFiles, commitMsg);
|
|
781
|
+
|
|
782
|
+
if (!commitResult.success) {
|
|
783
|
+
// Hook failed (or git itself failed). Audit stays at applyState:'pending'; staged files
|
|
784
|
+
// remain staged for the user to resolve.
|
|
785
|
+
return {
|
|
786
|
+
applied: false,
|
|
787
|
+
reason: 'pending-hook-fail',
|
|
788
|
+
error: commitResult.error,
|
|
789
|
+
audit,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
audit.applyState = 'committed';
|
|
794
|
+
audit.commitHash = commitResult.commitHash;
|
|
795
|
+
writeJson(appliedAuditPath(projectRoot, patternId), audit);
|
|
796
|
+
|
|
797
|
+
return { applied: true, commitHash: commitResult.commitHash, audit };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Retry the commit for a prior pending audit. The state was already mutated on the original
|
|
802
|
+
* applyPattern call; we only re-stage + commit. On success, promote the audit to
|
|
803
|
+
* applyState:'committed' and return.
|
|
804
|
+
*
|
|
805
|
+
* @cap-decision(F-074/D7) Retry skips state mutation. The L3 file is still patched on disk;
|
|
806
|
+
* applied-state.json is still updated. The user fixed the hook issue
|
|
807
|
+
* (e.g. lint), and the commit can now go through.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} projectRoot
|
|
810
|
+
* @param {AppliedAuditRecord} priorAudit
|
|
811
|
+
* @returns {{applied:true, commitHash:string|null, audit:AppliedAuditRecord}|{applied:false, reason:'pending-hook-fail', error:string, audit:AppliedAuditRecord}}
|
|
812
|
+
*/
|
|
813
|
+
function retryApplyCommit(projectRoot, priorAudit) {
|
|
814
|
+
const featureRef = priorAudit.featureRef;
|
|
815
|
+
const commitMsg = featureRef
|
|
816
|
+
? `learn: apply ${priorAudit.patternId} (${featureRef})`
|
|
817
|
+
: `learn: apply ${priorAudit.patternId}`;
|
|
818
|
+
const commitResult = gitStageAndCommit(projectRoot, priorAudit.targetFiles, commitMsg);
|
|
819
|
+
if (!commitResult.success) {
|
|
820
|
+
return { applied: false, reason: 'pending-hook-fail', error: commitResult.error, audit: priorAudit };
|
|
821
|
+
}
|
|
822
|
+
const updated = { ...priorAudit, applyState: 'committed', commitHash: commitResult.commitHash };
|
|
823
|
+
writeJson(appliedAuditPath(projectRoot, priorAudit.patternId), updated);
|
|
824
|
+
return { applied: true, commitHash: commitResult.commitHash, audit: updated };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// -----------------------------------------------------------------------------
|
|
828
|
+
// Public API — unlearnPattern (AC-3, AC-4, AC-7)
|
|
829
|
+
// -----------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
// @cap-todo(ac:F-074/AC-3) /cap:learn unlearn <P-ID> generates a reverse patch, applies it,
|
|
832
|
+
// commits as `learn: unlearn P-NNN`.
|
|
833
|
+
// @cap-todo(ac:F-074/AC-4) Unlearn audit at .cap/learning/unlearned/P-NNN.json with
|
|
834
|
+
// {reason:'manual'|'auto-retract', ts, commitHash}.
|
|
835
|
+
// @cap-todo(ac:F-074/AC-7) Idempotency — second call on already-unlearned pattern is a no-op.
|
|
836
|
+
/**
|
|
837
|
+
* Unlearn a pattern: reverse the apply, write the unlearn audit, create the unlearn commit.
|
|
838
|
+
*
|
|
839
|
+
* @cap-risk(F-074/AC-7) Idempotency guard at the top — read the unlearned audit BEFORE any state
|
|
840
|
+
* mutation. A regression that skips this would double-commit.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} projectRoot
|
|
843
|
+
* @param {string} patternId
|
|
844
|
+
* @param {Object} [options]
|
|
845
|
+
* @param {'manual'|'auto-retract'} [options.reason] - Default 'manual'.
|
|
846
|
+
* @param {Date|string} [options.now]
|
|
847
|
+
* @returns {{unlearned:true, commitHash:string|null, audit:UnlearnedAuditRecord}
|
|
848
|
+
* | {unlearned:false, reason:'already-unlearned'|'l3-drift'|'apply-not-found'|'pending-hook-fail'|'invalid-pattern-id'|'invalid-project-root'|'l3-target-missing'|'l3-read-failed'|'l3-write-failed'|'l3-target-not-allowed', priorRecord?:UnlearnedAuditRecord, error?:string, commitHashToRevert?:string|null}}
|
|
849
|
+
*/
|
|
850
|
+
function unlearnPattern(projectRoot, patternId, options) {
|
|
851
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
852
|
+
return { unlearned: false, reason: 'invalid-project-root' };
|
|
853
|
+
}
|
|
854
|
+
if (!isValidPatternId(patternId)) {
|
|
855
|
+
return { unlearned: false, reason: 'invalid-pattern-id' };
|
|
856
|
+
}
|
|
857
|
+
const opts = options || {};
|
|
858
|
+
const reason = opts.reason === 'auto-retract' ? 'auto-retract' : 'manual';
|
|
859
|
+
const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
860
|
+
|
|
861
|
+
// @cap-risk(F-074/AC-7) Idempotency gate — return early if the unlearn audit already exists.
|
|
862
|
+
const priorUnlearned = readJson(unlearnedAuditPath(projectRoot, patternId));
|
|
863
|
+
if (priorUnlearned) {
|
|
864
|
+
return { unlearned: false, reason: 'already-unlearned', priorRecord: priorUnlearned };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// The apply audit must exist to unlearn against.
|
|
868
|
+
const applyAudit = readJson(appliedAuditPath(projectRoot, patternId));
|
|
869
|
+
if (!applyAudit) {
|
|
870
|
+
return { unlearned: false, reason: 'apply-not-found' };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Reverse the level-specific change.
|
|
874
|
+
const state = readAppliedState(projectRoot);
|
|
875
|
+
const level = applyAudit.level;
|
|
876
|
+
/** @type {string[]} */
|
|
877
|
+
const targetFiles = [
|
|
878
|
+
path.posix.join(CAP_DIR, LEARNING_DIR, UNLEARNED_DIR, `${patternId}.json`),
|
|
879
|
+
path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_STATE_FILE),
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
if (level === 'L1') {
|
|
883
|
+
const diff = applyAudit.beforeAfterDiff && applyAudit.beforeAfterDiff.L1;
|
|
884
|
+
reverseL1(state, diff);
|
|
885
|
+
} else if (level === 'L2') {
|
|
886
|
+
reverseL2(state, patternId);
|
|
887
|
+
} else if (level === 'L3') {
|
|
888
|
+
const diff = applyAudit.beforeAfterDiff && applyAudit.beforeAfterDiff.L3;
|
|
889
|
+
const result = reverseL3(projectRoot, diff);
|
|
890
|
+
if (!result.success) {
|
|
891
|
+
// L3 drift / read-failure / etc. — refuse without committing.
|
|
892
|
+
// @cap-decision(F-074/D4) On l3-drift, surface the prior apply commit hash so the user can
|
|
893
|
+
// `git revert <apply-hash>` manually. This is THE escape hatch.
|
|
894
|
+
return {
|
|
895
|
+
unlearned: false,
|
|
896
|
+
reason: result.reason,
|
|
897
|
+
commitHashToRevert: applyAudit.commitHash || null,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
// Remove the matching l3 entry from applied-state.
|
|
901
|
+
state.l3 = state.l3.filter((entry) => entry && entry.patternId !== patternId);
|
|
902
|
+
targetFiles.push(diff.file);
|
|
903
|
+
} else {
|
|
904
|
+
return { unlearned: false, reason: 'apply-not-found' };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
writeAppliedState(projectRoot, state);
|
|
908
|
+
|
|
909
|
+
// Persist the unlearn audit BEFORE the commit (mirror the apply path).
|
|
910
|
+
/** @type {UnlearnedAuditRecord} */
|
|
911
|
+
const audit = {
|
|
912
|
+
id: patternId,
|
|
913
|
+
patternId,
|
|
914
|
+
unlearnedAt: nowIso,
|
|
915
|
+
reason,
|
|
916
|
+
commitHash: null,
|
|
917
|
+
appliedCommitHash: applyAudit.commitHash || null,
|
|
918
|
+
};
|
|
919
|
+
writeJson(unlearnedAuditPath(projectRoot, patternId), audit);
|
|
920
|
+
|
|
921
|
+
const commitMsg = `learn: unlearn ${patternId}`;
|
|
922
|
+
const commitResult = gitStageAndCommit(projectRoot, targetFiles, commitMsg);
|
|
923
|
+
if (!commitResult.success) {
|
|
924
|
+
// Audit stays in place with commitHash:null. The user can resolve manually.
|
|
925
|
+
return { unlearned: false, reason: 'pending-hook-fail', error: commitResult.error };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
audit.commitHash = commitResult.commitHash;
|
|
929
|
+
writeJson(unlearnedAuditPath(projectRoot, patternId), audit);
|
|
930
|
+
|
|
931
|
+
return { unlearned: true, commitHash: commitResult.commitHash, audit };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// -----------------------------------------------------------------------------
|
|
935
|
+
// Public API — listAppliedPatterns / listUnlearnedPatterns
|
|
936
|
+
// -----------------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Read every persisted apply audit. Tolerant to missing dir + malformed files.
|
|
940
|
+
* @param {string} projectRoot
|
|
941
|
+
* @returns {AppliedAuditRecord[]}
|
|
942
|
+
*/
|
|
943
|
+
function listAppliedPatterns(projectRoot) {
|
|
944
|
+
const dir = appliedDir(projectRoot);
|
|
945
|
+
if (!fs.existsSync(dir)) return [];
|
|
946
|
+
let entries;
|
|
947
|
+
try {
|
|
948
|
+
entries = fs.readdirSync(dir);
|
|
949
|
+
} catch (_e) {
|
|
950
|
+
return [];
|
|
951
|
+
}
|
|
952
|
+
const out = [];
|
|
953
|
+
for (const f of entries) {
|
|
954
|
+
if (!/^P-\d+\.json$/.test(f)) continue;
|
|
955
|
+
const parsed = readJson(path.join(dir, f));
|
|
956
|
+
if (parsed) out.push(parsed);
|
|
957
|
+
}
|
|
958
|
+
// Sort by patternId ascending for deterministic output.
|
|
959
|
+
out.sort((a, b) => {
|
|
960
|
+
const ai = (a && a.patternId) || '';
|
|
961
|
+
const bi = (b && b.patternId) || '';
|
|
962
|
+
if (ai < bi) return -1;
|
|
963
|
+
if (ai > bi) return 1;
|
|
964
|
+
return 0;
|
|
965
|
+
});
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Read every persisted unlearn audit. Tolerant to missing dir + malformed files.
|
|
971
|
+
* @param {string} projectRoot
|
|
972
|
+
* @returns {UnlearnedAuditRecord[]}
|
|
973
|
+
*/
|
|
974
|
+
function listUnlearnedPatterns(projectRoot) {
|
|
975
|
+
const dir = unlearnedDir(projectRoot);
|
|
976
|
+
if (!fs.existsSync(dir)) return [];
|
|
977
|
+
let entries;
|
|
978
|
+
try {
|
|
979
|
+
entries = fs.readdirSync(dir);
|
|
980
|
+
} catch (_e) {
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
const out = [];
|
|
984
|
+
for (const f of entries) {
|
|
985
|
+
if (!/^P-\d+\.json$/.test(f)) continue;
|
|
986
|
+
const parsed = readJson(path.join(dir, f));
|
|
987
|
+
if (parsed) out.push(parsed);
|
|
988
|
+
}
|
|
989
|
+
out.sort((a, b) => {
|
|
990
|
+
const ai = (a && a.patternId) || '';
|
|
991
|
+
const bi = (b && b.patternId) || '';
|
|
992
|
+
if (ai < bi) return -1;
|
|
993
|
+
if (ai > bi) return 1;
|
|
994
|
+
return 0;
|
|
995
|
+
});
|
|
996
|
+
return out;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// -----------------------------------------------------------------------------
|
|
1000
|
+
// Public API — listRetractRecommended (AC-5, F-073 hook point)
|
|
1001
|
+
// -----------------------------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
// @cap-todo(ac:F-074/AC-5) Read the retract list (.jsonl) and return de-duped pattern ids.
|
|
1004
|
+
// @cap-todo(ac:F-074/AC-6) F-073 review board reads this list to label patterns "Rückzug empfohlen"
|
|
1005
|
+
// and offer a one-click unlearn affordance. F-074 only EXPOSES the list;
|
|
1006
|
+
// F-073 wires the UI. This is intentionally a follow-up.
|
|
1007
|
+
/**
|
|
1008
|
+
* Read the retract-recommendations.jsonl and return the unique pattern ids most-recently
|
|
1009
|
+
* recommended for retraction. De-dup by patternId — most-recent line wins. Patterns that
|
|
1010
|
+
* have ALREADY been unlearned are filtered out (the recommendation is moot).
|
|
1011
|
+
*
|
|
1012
|
+
* @param {string} projectRoot
|
|
1013
|
+
* @returns {string[]} Sorted ascending for deterministic output.
|
|
1014
|
+
*/
|
|
1015
|
+
function listRetractRecommended(projectRoot) {
|
|
1016
|
+
const fp = retractRecommendationsPath(projectRoot);
|
|
1017
|
+
if (!fs.existsSync(fp)) return [];
|
|
1018
|
+
|
|
1019
|
+
let raw;
|
|
1020
|
+
try {
|
|
1021
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
1022
|
+
} catch (_e) {
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Most-recent-wins de-dup: walk the file in order, overwrite per-id.
|
|
1027
|
+
/** @type {Map<string, object>} */
|
|
1028
|
+
const byId = new Map();
|
|
1029
|
+
for (const line of raw.split('\n')) {
|
|
1030
|
+
if (!line) continue;
|
|
1031
|
+
try {
|
|
1032
|
+
const parsed = JSON.parse(line);
|
|
1033
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
1034
|
+
if (typeof parsed.patternId !== 'string' || !PATTERN_ID_RE.test(parsed.patternId)) continue;
|
|
1035
|
+
byId.set(parsed.patternId, parsed);
|
|
1036
|
+
} catch (_e) {
|
|
1037
|
+
// Skip malformed lines — never throw.
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Filter out patterns that have been unlearned already.
|
|
1042
|
+
const unlearned = new Set(listUnlearnedPatterns(projectRoot).map((u) => u.patternId));
|
|
1043
|
+
const out = [];
|
|
1044
|
+
for (const id of byId.keys()) {
|
|
1045
|
+
if (!unlearned.has(id)) out.push(id);
|
|
1046
|
+
}
|
|
1047
|
+
out.sort();
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// -----------------------------------------------------------------------------
|
|
1052
|
+
// Public API — runRetractCheck (AC-5)
|
|
1053
|
+
// -----------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
// @cap-todo(ac:F-074/AC-5) 5-session post-apply check: for each applied pattern, count distinct
|
|
1056
|
+
// override-corpus sessions since apply. If >=5 AND current Layer-1 override-rate
|
|
1057
|
+
// is worse than fitnessSnapshot.layer1.value, append to retract-recommendations.jsonl.
|
|
1058
|
+
/**
|
|
1059
|
+
* Walk every applied pattern; for each, count distinct override sessions since the apply timestamp.
|
|
1060
|
+
* When that count >= window AND the current Layer-1 override-count is worse than the snapshot's,
|
|
1061
|
+
* append a retract recommendation to .cap/learning/retract-recommendations.jsonl.
|
|
1062
|
+
*
|
|
1063
|
+
* @cap-decision(F-074/D5) "Worse" = currentLayer1.value > snapshotLayer1.value (more overrides =
|
|
1064
|
+
* pattern hurting more). When equal or better, no recommendation.
|
|
1065
|
+
*
|
|
1066
|
+
* @cap-decision(F-074/D5) "Sessions since apply" is computed from the F-070 override corpus —
|
|
1067
|
+
* distinct sessionIds whose ts > applyAuditAppliedAt. We do NOT use git
|
|
1068
|
+
* commit history (would couple us to git rebase semantics; brittle).
|
|
1069
|
+
*
|
|
1070
|
+
* @param {string} projectRoot
|
|
1071
|
+
* @param {Object} [options]
|
|
1072
|
+
* @param {number} [options.window] - Override RETRACT_SESSION_THRESHOLD (mostly for tests).
|
|
1073
|
+
* @param {Date|string} [options.now] - Timestamp for the appended JSONL line (default Date.now()).
|
|
1074
|
+
* @returns {{checked:string[], recommended:string[], errors:string[]}}
|
|
1075
|
+
*/
|
|
1076
|
+
function runRetractCheck(projectRoot, options) {
|
|
1077
|
+
const opts = options || {};
|
|
1078
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : RETRACT_SESSION_THRESHOLD;
|
|
1079
|
+
const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
1080
|
+
|
|
1081
|
+
/** @type {string[]} */
|
|
1082
|
+
const checked = [];
|
|
1083
|
+
/** @type {string[]} */
|
|
1084
|
+
const recommended = [];
|
|
1085
|
+
/** @type {string[]} */
|
|
1086
|
+
const errors = [];
|
|
1087
|
+
|
|
1088
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
1089
|
+
return { checked, recommended, errors: ['projectRoot is required'] };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Read the override corpus ONCE — performance bound (100 patterns × 1000 signals < 500ms).
|
|
1093
|
+
let overrides = [];
|
|
1094
|
+
try {
|
|
1095
|
+
overrides = learningSignals.getSignals(projectRoot, 'override') || [];
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
errors.push(`getSignals(override) failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const applied = listAppliedPatterns(projectRoot);
|
|
1101
|
+
const unlearnedSet = new Set(listUnlearnedPatterns(projectRoot).map((u) => u.patternId));
|
|
1102
|
+
|
|
1103
|
+
for (const audit of applied) {
|
|
1104
|
+
if (!audit || !isValidPatternId(audit.patternId)) continue;
|
|
1105
|
+
if (unlearnedSet.has(audit.patternId)) continue; // already retracted manually
|
|
1106
|
+
if (audit.applyState !== 'committed') continue; // pending applies aren't yet "live"
|
|
1107
|
+
|
|
1108
|
+
checked.push(audit.patternId);
|
|
1109
|
+
|
|
1110
|
+
// Count distinct override sessions whose ts > applyAuditAppliedAt.
|
|
1111
|
+
const since = audit.appliedAt;
|
|
1112
|
+
/** @type {Set<string>} */
|
|
1113
|
+
const sessionsSince = new Set();
|
|
1114
|
+
for (const r of overrides) {
|
|
1115
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
1116
|
+
if (typeof r.ts !== 'string') continue;
|
|
1117
|
+
if (r.ts <= since) continue;
|
|
1118
|
+
sessionsSince.add(r.sessionId);
|
|
1119
|
+
}
|
|
1120
|
+
const sessionsSinceApply = sessionsSince.size;
|
|
1121
|
+
if (sessionsSinceApply < window) continue; // not enough data yet
|
|
1122
|
+
|
|
1123
|
+
// Compare current Layer-1 to the apply-time snapshot. We re-compute current fitness via F-072
|
|
1124
|
+
// (single source of truth — never duplicate the formula).
|
|
1125
|
+
let current;
|
|
1126
|
+
try {
|
|
1127
|
+
current = fitnessScore.computeFitness(projectRoot, audit.patternId);
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
errors.push(`computeFitness threw for ${audit.patternId}: ${e && e.message ? e.message : 'unknown'}`);
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
if (!current || !current.layer1) continue;
|
|
1133
|
+
|
|
1134
|
+
const snapshotL1 = audit.fitnessSnapshot && audit.fitnessSnapshot.layer1
|
|
1135
|
+
? Number(audit.fitnessSnapshot.layer1.value) || 0
|
|
1136
|
+
: 0;
|
|
1137
|
+
const currentL1 = Number(current.layer1.value) || 0;
|
|
1138
|
+
|
|
1139
|
+
if (currentL1 > snapshotL1) {
|
|
1140
|
+
// @cap-risk(F-074/AC-2) Append-only JSONL — never overwrite. F-073 reads this file via
|
|
1141
|
+
// listRetractRecommended() which de-dups (most-recent-wins).
|
|
1142
|
+
const line = JSON.stringify({
|
|
1143
|
+
ts: nowIso,
|
|
1144
|
+
patternId: audit.patternId,
|
|
1145
|
+
sessionsSinceApply,
|
|
1146
|
+
snapshot: snapshotL1,
|
|
1147
|
+
current: currentL1,
|
|
1148
|
+
reason: 'override-rate-worse',
|
|
1149
|
+
}) + '\n';
|
|
1150
|
+
try {
|
|
1151
|
+
ensureDir(learningRoot(projectRoot));
|
|
1152
|
+
const fd = fs.openSync(retractRecommendationsPath(projectRoot), 'a');
|
|
1153
|
+
try {
|
|
1154
|
+
fs.writeSync(fd, line);
|
|
1155
|
+
} finally {
|
|
1156
|
+
fs.closeSync(fd);
|
|
1157
|
+
}
|
|
1158
|
+
recommended.push(audit.patternId);
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
errors.push(`append retract-recommendations failed for ${audit.patternId}: ${e && e.message ? e.message : 'unknown'}`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return { checked, recommended, errors };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// -----------------------------------------------------------------------------
|
|
1169
|
+
// Exports — keep this list minimal. F-073 / /cap:learn should consume only these.
|
|
1170
|
+
// -----------------------------------------------------------------------------
|
|
1171
|
+
|
|
1172
|
+
module.exports = {
|
|
1173
|
+
// Constants — exported for tests + downstream consumers.
|
|
1174
|
+
CAP_DIR,
|
|
1175
|
+
LEARNING_DIR,
|
|
1176
|
+
APPLIED_DIR,
|
|
1177
|
+
UNLEARNED_DIR,
|
|
1178
|
+
APPLIED_STATE_FILE,
|
|
1179
|
+
RETRACT_RECOMMENDATIONS_FILE,
|
|
1180
|
+
RETRACT_SESSION_THRESHOLD,
|
|
1181
|
+
APPLIED_STATE_VERSION,
|
|
1182
|
+
L3_TARGET_PREFIXES,
|
|
1183
|
+
// Public API.
|
|
1184
|
+
applyPattern,
|
|
1185
|
+
unlearnPattern,
|
|
1186
|
+
listAppliedPatterns,
|
|
1187
|
+
listUnlearnedPatterns,
|
|
1188
|
+
listRetractRecommended,
|
|
1189
|
+
runRetractCheck,
|
|
1190
|
+
readAppliedState,
|
|
1191
|
+
writeAppliedState,
|
|
1192
|
+
// Path helpers — exported for tests.
|
|
1193
|
+
appliedDir,
|
|
1194
|
+
unlearnedDir,
|
|
1195
|
+
appliedStateFilePath,
|
|
1196
|
+
retractRecommendationsPath,
|
|
1197
|
+
appliedAuditPath,
|
|
1198
|
+
unlearnedAuditPath,
|
|
1199
|
+
// Helpers exposed for tests / introspection — keep this list small.
|
|
1200
|
+
isAllowedL3Target,
|
|
1201
|
+
gitStageAndCommit,
|
|
1202
|
+
currentSessionId,
|
|
1203
|
+
};
|