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,1072 @@
|
|
|
1
|
+
// @cap-context CAP F-073 Review Patterns via Learn Command — final piece of the V5 self-learning
|
|
2
|
+
// loop. Consumes F-071 patterns, F-072 fitness/confidence, F-074 applied/unlearned/
|
|
3
|
+
// retract-recommended state, computes a per-session "pending review" set, renders a
|
|
4
|
+
// human-friendly board.md, and exposes skip/reject/archive helpers + a Stop-hook gate.
|
|
5
|
+
// PURE-COMPUTE + small persistence: writes board.md, skipped/rejected JSONs, archive
|
|
6
|
+
// files, and the board-pending.flag. Never spawns the LLM, never writes git commits.
|
|
7
|
+
// The /cap:learn review skill orchestrates apply/unlearn by delegating to F-074.
|
|
8
|
+
// @cap-decision(F-073/D1) Stop-Hook integration — a separate hook file, hooks/cap-learn-review-hook.js,
|
|
9
|
+
// fires AFTER cap-memory's Stop hook (memory pipeline → learn pipeline → review board).
|
|
10
|
+
// The hook only computes shouldShowBoard() and writes a tiny .cap/learning/board-pending.flag
|
|
11
|
+
// on positive gate; it NEVER spawns the skill (Claude Code hook subprocesses can't drive an
|
|
12
|
+
// interactive flow). Same fail-silent posture as cap-learning-hook.js: on any error the hook
|
|
13
|
+
// exits 0 so a session can never be blocked. Lib-resolution mirrors cap-learning-hook.js
|
|
14
|
+
// (env override → colocated → ~/.claude). Skip via CAP_SKIP_LEARN_REVIEW_HOOK=1.
|
|
15
|
+
// @cap-decision(F-073/D2) Review UX via Briefing-pattern — mirrors F-071's LLM Skill-Briefing. The skill
|
|
16
|
+
// renders board.md with all eligible patterns + per-pattern options + retract labels, then
|
|
17
|
+
// INSTRUCTS the outer agent to read board.md, decide approve/reject/skip/unlearn per
|
|
18
|
+
// pattern, and call cap-pattern-apply.applyPattern / unlearnPattern (or our skipPattern /
|
|
19
|
+
// rejectPattern helpers). The skill exit code follows the AC-7 contract: ANY apply that
|
|
20
|
+
// returns applied:false → non-zero exit. There is NO interactive CLI subprocess.
|
|
21
|
+
// @cap-decision(F-073/D3) Eligibility = persisted in .cap/learning/patterns/ AND not in applied/ AND not
|
|
22
|
+
// in unlearned/ AND not in archive/ AND not in this-session's skipped-<sid>.json AND not
|
|
23
|
+
// in this-session's rejected-<sid>.json. Skipping is per-SESSION only (not a persistent
|
|
24
|
+
// mute) — a new session re-shows the patterns. Rejection is also per-session (the user
|
|
25
|
+
// may want to reconsider next session); persistence beyond a session would require a
|
|
26
|
+
// separate "permanent rejected" store, which is out of scope for F-073.
|
|
27
|
+
// @cap-decision(F-073/D4) Threshold gate (AC-2) — board appears only when:
|
|
28
|
+
// (a) ≥ 1 high-confidence eligible pattern: layer2.ready=true AND layer2.value >= 0.75
|
|
29
|
+
// AND layer2.n >= 5 (the F-072 confidence threshold), OR
|
|
30
|
+
// (b) ≥ 3 eligible candidates of any kind (any level / source / fitness).
|
|
31
|
+
// Below the gate, the skill exits 0 silently with a "no review needed" log line. The
|
|
32
|
+
// hook uses the same gate so the .flag file is only written when the user would actually
|
|
33
|
+
// see something. "high-confidence" uses the fitness layer2 reading because Layer-2 is
|
|
34
|
+
// the "long-term per-session weighted average" that signals the pattern's territory has
|
|
35
|
+
// been trustably useful — a fresh n=2 candidate with layer2.ready=false is NOT high-
|
|
36
|
+
// confidence regardless of its layer2.value snapshot. The gate is computed from the
|
|
37
|
+
// ELIGIBLE set (D3), not the raw persisted set, so applied/unlearned patterns can never
|
|
38
|
+
// contribute to "≥3" double-counting once they've left review scope.
|
|
39
|
+
// @cap-decision(F-073/D5) Stale-archive (AC-5) — patterns un-reviewed for > 7 sessions auto-archive to
|
|
40
|
+
// .cap/learning/archive/<P-NNN>.json AND are removed from .cap/learning/patterns/. The
|
|
41
|
+
// "session count" comes from the F-070 signal corpus — count distinct sessionIds with
|
|
42
|
+
// ts >= pattern.createdAt across the union of override / memory-ref / regret signals.
|
|
43
|
+
// If the corpus has fewer than 7 distinct sessions total → NO archive (insufficient
|
|
44
|
+
// data — F-072's expiry rule uses the same insufficient-history short-circuit). We do
|
|
45
|
+
// NOT archive applied/unlearned patterns (they've already left review) and we do NOT
|
|
46
|
+
// archive patterns that were skipped/rejected this session (the skip/reject is the
|
|
47
|
+
// user's "still aware of it" signal). Archive is idempotent: re-running on an already-
|
|
48
|
+
// archived id is a no-op; missing-source-pattern → recorded as error, not a throw.
|
|
49
|
+
// F-072's unionSessionsByRecency is NOT exported, so we replicate the simple count
|
|
50
|
+
// here (count distinct sessionIds across the three corpora).
|
|
51
|
+
// @cap-decision(F-073/D6) Atomic write contract (mirrors F-074/D8) — every JSON / md write that's not
|
|
52
|
+
// a one-shot append-only flag goes through the writeAtomic helper: write to .tmp,
|
|
53
|
+
// fs.renameSync into place. POSIX rename(2) is atomic; an interrupted write leaves a
|
|
54
|
+
// .tmp orphan we clean up on the next attempt rather than a half-written board.md that
|
|
55
|
+
// the outer agent might process. The flag file is small and write-truncate is fine —
|
|
56
|
+
// a half-written flag is harmless because the .json content isn't parsed by the skill
|
|
57
|
+
// (presence is the signal).
|
|
58
|
+
// @cap-constraint Zero external dependencies: node:fs + node:path only. Always go through F-071/F-072/
|
|
59
|
+
// F-074 module APIs — never read pattern/fitness/applied JSONs directly. cap-session
|
|
60
|
+
// is read via a tiny inline helper (mirrors F-074's currentSessionId pattern); we
|
|
61
|
+
// don't take a hard dep on cap-session to keep the resolver-graph identical to F-074.
|
|
62
|
+
// @cap-risk(F-073/AC-7) The approve→applyPattern call site is a CRITICAL SURFACE. The skill must
|
|
63
|
+
// propagate applied:false to a non-zero exit code; a regression that swallows the
|
|
64
|
+
// result would silently apply nothing while reporting success. The skill orchestration
|
|
65
|
+
// lives in commands/cap/learn.md (Subcommand: review). This module exposes the inputs
|
|
66
|
+
// the skill needs to make that decision — it does NOT call applyPattern itself
|
|
67
|
+
// (separation of concerns: F-073 = compute + render + skip/reject/archive; F-074 =
|
|
68
|
+
// apply/unlearn). The board.md hand-off documents the contract so the outer agent
|
|
69
|
+
// reports back faithfully.
|
|
70
|
+
// @cap-risk(F-073/AC-2) The threshold gate is the only thing standing between a noisy first session
|
|
71
|
+
// (lots of low-confidence candidates) and a flood of useless review prompts. A
|
|
72
|
+
// regression that loosens the gate would burn the user's attention. Adversarial test
|
|
73
|
+
// pins the boundary cases (exactly 3 / exactly 2 / exactly 1 high-confidence).
|
|
74
|
+
// @cap-risk(F-073/AC-4) The skipped-<sid>.json file shape is per-session ONLY. A regression that
|
|
75
|
+
// wrote it as a global skip-mute would silently hide patterns indefinitely. Tests
|
|
76
|
+
// pin: same-session re-read excludes ids; new-session re-read shows them again.
|
|
77
|
+
|
|
78
|
+
'use strict';
|
|
79
|
+
|
|
80
|
+
// @cap-feature(feature:F-073, primary:true) Review Patterns via Learn Command — board renderer +
|
|
81
|
+
// eligibility computation + skip/reject/archive helpers
|
|
82
|
+
// + Stop-hook gate.
|
|
83
|
+
|
|
84
|
+
const fs = require('node:fs');
|
|
85
|
+
const path = require('node:path');
|
|
86
|
+
|
|
87
|
+
const patternPipeline = require('./cap-pattern-pipeline.cjs');
|
|
88
|
+
const fitnessScore = require('./cap-fitness-score.cjs');
|
|
89
|
+
const patternApply = require('./cap-pattern-apply.cjs');
|
|
90
|
+
const learningSignals = require('./cap-learning-signals.cjs');
|
|
91
|
+
|
|
92
|
+
// -----------------------------------------------------------------------------
|
|
93
|
+
// Constants — kept top-of-file so consumers (the /cap:learn review skill, the
|
|
94
|
+
// Stop-hook, tests) reference exactly one place. Mirrors layout of
|
|
95
|
+
// cap-pattern-apply.cjs and cap-fitness-score.cjs.
|
|
96
|
+
// -----------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const CAP_DIR = '.cap';
|
|
99
|
+
const LEARNING_DIR = 'learning';
|
|
100
|
+
const PATTERNS_DIR = 'patterns';
|
|
101
|
+
const ARCHIVE_DIR = 'archive';
|
|
102
|
+
const BOARD_FILE = 'board.md';
|
|
103
|
+
const BOARD_PENDING_FLAG = 'board-pending.flag';
|
|
104
|
+
|
|
105
|
+
// AC-2 threshold knobs (D4). Centralised so a future tuning lives in ONE place;
|
|
106
|
+
// the adversarial test verifies exact behaviour.
|
|
107
|
+
const HIGH_CONFIDENCE_LAYER2_VALUE = 0.75;
|
|
108
|
+
const HIGH_CONFIDENCE_LAYER2_N = 5;
|
|
109
|
+
const ANY_KIND_THRESHOLD = 3;
|
|
110
|
+
|
|
111
|
+
// AC-5 stale-archive knob.
|
|
112
|
+
const STALE_SESSION_THRESHOLD = 7;
|
|
113
|
+
|
|
114
|
+
// Pattern-id format mirror.
|
|
115
|
+
const PATTERN_ID_RE = /^P-\d+$/;
|
|
116
|
+
|
|
117
|
+
// SessionId sanitisation guards (AC-7 / privacy).
|
|
118
|
+
// Mirrors cap-fitness-score's sessionId guards — the sessionId can flow into:
|
|
119
|
+
// 1. the skipped/rejected JSON's `sessionId` field (round-trips back to the
|
|
120
|
+
// review board in the same session), and
|
|
121
|
+
// 2. the board-pending.flag's `sessionId` field.
|
|
122
|
+
// A hostile sessionId could otherwise smuggle markdown / JSON / control bytes
|
|
123
|
+
// into either file. We refuse anything outside the SESSION_ID_RE alphabet and
|
|
124
|
+
// truncate at SESSION_ID_MAX before persisting.
|
|
125
|
+
const SESSION_ID_MAX = 200;
|
|
126
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {Object} EligibleEntry
|
|
130
|
+
* @property {string} patternId - 'P-NNN'.
|
|
131
|
+
* @property {object|null} fitness - The full F-072 FitnessRecord, or null when missing.
|
|
132
|
+
* @property {number} confidence - 0..1 derived from fitness (D4) for board display.
|
|
133
|
+
* @property {string} triggerReason - Short description of WHY this pattern qualifies (e.g. 'override-cluster F-100', 'regret').
|
|
134
|
+
* @property {boolean} retractRecommended - True iff F-074 listRetractRecommended() includes this id.
|
|
135
|
+
* @property {string[]} options - The action options surfaced on the board ('Approve','Reject','Skip','Unlearn'?).
|
|
136
|
+
* @property {object} pattern - The full PatternRecord (for the renderer; not persisted in the JSON shape).
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} ReviewBoard
|
|
141
|
+
* @property {EligibleEntry[]} eligible
|
|
142
|
+
* @property {{met: boolean, reason: string}} threshold
|
|
143
|
+
* @property {string[]} archived - Pattern ids moved to archive/ THIS run.
|
|
144
|
+
* @property {string[]} skippedThisSession - Pattern ids in the session's skipped file.
|
|
145
|
+
* @property {string[]} rejectedThisSession - Pattern ids in the session's rejected file.
|
|
146
|
+
* @property {string|null} sessionId - Session id used for skip/reject scoping.
|
|
147
|
+
* @property {string} ts - ISO timestamp of board build.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
// -----------------------------------------------------------------------------
|
|
151
|
+
// Internal helpers — directory + IO
|
|
152
|
+
// -----------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function ensureDir(dir) {
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
157
|
+
} catch (_e) {
|
|
158
|
+
// Boundary callers swallow; the next write surfaces persistent IO problems.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function learningRoot(projectRoot) {
|
|
163
|
+
return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function patternsDir(projectRoot) {
|
|
167
|
+
return path.join(learningRoot(projectRoot), PATTERNS_DIR);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function archiveDir(projectRoot) {
|
|
171
|
+
return path.join(learningRoot(projectRoot), ARCHIVE_DIR);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function boardFilePath(projectRoot) {
|
|
175
|
+
return path.join(learningRoot(projectRoot), BOARD_FILE);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function boardPendingFlagPath(projectRoot) {
|
|
179
|
+
return path.join(learningRoot(projectRoot), BOARD_PENDING_FLAG);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function skippedFilePath(projectRoot, sessionId) {
|
|
183
|
+
return path.join(learningRoot(projectRoot), `skipped-${sessionId}.json`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function rejectedFilePath(projectRoot, sessionId) {
|
|
187
|
+
return path.join(learningRoot(projectRoot), `rejected-${sessionId}.json`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function archiveFilePath(projectRoot, patternId) {
|
|
191
|
+
return path.join(archiveDir(projectRoot), `${patternId}.json`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function patternFilePath(projectRoot, patternId) {
|
|
195
|
+
return path.join(patternsDir(projectRoot), `${patternId}.json`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate a P-NNN id. Every public boundary routes through this gate.
|
|
200
|
+
* @param {any} id
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
function isValidPatternId(id) {
|
|
204
|
+
return typeof id === 'string' && PATTERN_ID_RE.test(id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Sanitise a sessionId. Returns null when the id is missing/invalid; otherwise
|
|
209
|
+
* returns the (truncated) id verified against SESSION_ID_RE.
|
|
210
|
+
* @cap-risk(F-073/AC-3) The sessionId flows into the .flag file's JSON body and
|
|
211
|
+
* file paths. A hostile sessionId could otherwise inject
|
|
212
|
+
* newlines / JSON-control / path-traversal segments.
|
|
213
|
+
* @param {any} v
|
|
214
|
+
* @returns {string|null}
|
|
215
|
+
*/
|
|
216
|
+
function sanitiseSessionId(v) {
|
|
217
|
+
if (typeof v !== 'string' || v.length === 0) return null;
|
|
218
|
+
const trimmed = v.length > SESSION_ID_MAX ? v.slice(0, SESSION_ID_MAX) : v;
|
|
219
|
+
if (!SESSION_ID_RE.test(trimmed)) return null;
|
|
220
|
+
return trimmed;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read the SESSION.json sessionId, if any. Mirrors the F-074 helper exactly.
|
|
225
|
+
* @param {string} projectRoot
|
|
226
|
+
* @returns {string|null}
|
|
227
|
+
*/
|
|
228
|
+
function currentSessionId(projectRoot) {
|
|
229
|
+
try {
|
|
230
|
+
const fp = path.join(projectRoot, CAP_DIR, 'SESSION.json');
|
|
231
|
+
if (!fs.existsSync(fp)) return null;
|
|
232
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
233
|
+
const parsed = JSON.parse(raw);
|
|
234
|
+
if (parsed && typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
|
|
235
|
+
return sanitiseSessionId(parsed.sessionId);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
} catch (_e) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read a JSON file; return null on missing / malformed. Never throws.
|
|
245
|
+
* @param {string} fp
|
|
246
|
+
* @returns {any|null}
|
|
247
|
+
*/
|
|
248
|
+
function readJson(fp) {
|
|
249
|
+
try {
|
|
250
|
+
if (!fs.existsSync(fp)) return null;
|
|
251
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
252
|
+
const parsed = JSON.parse(raw);
|
|
253
|
+
return parsed;
|
|
254
|
+
} catch (_e) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Atomic write helper. Mirrors F-074/D8 pattern: write to .tmp, rename into place.
|
|
261
|
+
* @param {string} fp
|
|
262
|
+
* @param {string|Buffer} content
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
function writeAtomic(fp, content) {
|
|
266
|
+
try {
|
|
267
|
+
ensureDir(path.dirname(fp));
|
|
268
|
+
const tmp = fp + '.tmp';
|
|
269
|
+
fs.writeFileSync(tmp, content);
|
|
270
|
+
fs.renameSync(tmp, fp);
|
|
271
|
+
return true;
|
|
272
|
+
} catch (_e) {
|
|
273
|
+
try { fs.unlinkSync(fp + '.tmp'); } catch (_e2) { /* ignore */ }
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Atomic write of a JSON file (with trailing newline).
|
|
280
|
+
* @param {string} fp
|
|
281
|
+
* @param {object} data
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function writeAtomicJson(fp, data) {
|
|
285
|
+
return writeAtomic(fp, JSON.stringify(data, null, 2) + '\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// -----------------------------------------------------------------------------
|
|
289
|
+
// Internal helpers — skip / reject persistence
|
|
290
|
+
// -----------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Load the skipped-<sid>.json content for the given sessionId. Returns the
|
|
294
|
+
* patternIds array (de-duplicated).
|
|
295
|
+
* @param {string} projectRoot
|
|
296
|
+
* @param {string} sessionId
|
|
297
|
+
* @returns {string[]}
|
|
298
|
+
*/
|
|
299
|
+
function loadSkippedThisSession(projectRoot, sessionId) {
|
|
300
|
+
const sid = sanitiseSessionId(sessionId);
|
|
301
|
+
if (!sid) return [];
|
|
302
|
+
const parsed = readJson(skippedFilePath(projectRoot, sid));
|
|
303
|
+
if (!parsed || !Array.isArray(parsed.patternIds)) return [];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const id of parsed.patternIds) {
|
|
307
|
+
if (isValidPatternId(id) && !seen.has(id)) { seen.add(id); out.push(id); }
|
|
308
|
+
}
|
|
309
|
+
return out.sort();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Load the rejected-<sid>.json content for the given sessionId.
|
|
314
|
+
* @param {string} projectRoot
|
|
315
|
+
* @param {string} sessionId
|
|
316
|
+
* @returns {string[]}
|
|
317
|
+
*/
|
|
318
|
+
function loadRejectedThisSession(projectRoot, sessionId) {
|
|
319
|
+
const sid = sanitiseSessionId(sessionId);
|
|
320
|
+
if (!sid) return [];
|
|
321
|
+
const parsed = readJson(rejectedFilePath(projectRoot, sid));
|
|
322
|
+
if (!parsed || !Array.isArray(parsed.patternIds)) return [];
|
|
323
|
+
const seen = new Set();
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const id of parsed.patternIds) {
|
|
326
|
+
if (isValidPatternId(id) && !seen.has(id)) { seen.add(id); out.push(id); }
|
|
327
|
+
}
|
|
328
|
+
return out.sort();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -----------------------------------------------------------------------------
|
|
332
|
+
// Internal helpers — eligibility + presentation
|
|
333
|
+
// -----------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Compute the set of pattern ids eligible for review THIS session.
|
|
337
|
+
* D3: persisted ∧ ¬applied ∧ ¬unlearned ∧ ¬archived ∧ ¬skipped ∧ ¬rejected.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} projectRoot
|
|
340
|
+
* @param {string|null} sessionId - When null, only persistent state filters apply.
|
|
341
|
+
* @returns {{ ids: string[], skipped: string[], rejected: string[] }}
|
|
342
|
+
*/
|
|
343
|
+
function eligiblePatternIds(projectRoot, sessionId) {
|
|
344
|
+
let patterns = [];
|
|
345
|
+
try { patterns = patternPipeline.listPatterns(projectRoot) || []; } catch (_e) { patterns = []; }
|
|
346
|
+
|
|
347
|
+
const applied = new Set((patternApply.listAppliedPatterns(projectRoot) || [])
|
|
348
|
+
.map((a) => a && a.patternId).filter(isValidPatternId));
|
|
349
|
+
const unlearned = new Set((patternApply.listUnlearnedPatterns(projectRoot) || [])
|
|
350
|
+
.map((u) => u && u.patternId).filter(isValidPatternId));
|
|
351
|
+
|
|
352
|
+
// Archived is a directory listing, not a module API.
|
|
353
|
+
const archived = new Set();
|
|
354
|
+
try {
|
|
355
|
+
const dir = archiveDir(projectRoot);
|
|
356
|
+
if (fs.existsSync(dir)) {
|
|
357
|
+
for (const f of fs.readdirSync(dir)) {
|
|
358
|
+
if (!/^P-\d+\.json$/.test(f)) continue;
|
|
359
|
+
archived.add(f.slice(0, -'.json'.length));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (_e) { /* ignore */ }
|
|
363
|
+
|
|
364
|
+
const skipped = sessionId ? loadSkippedThisSession(projectRoot, sessionId) : [];
|
|
365
|
+
const rejected = sessionId ? loadRejectedThisSession(projectRoot, sessionId) : [];
|
|
366
|
+
const skippedSet = new Set(skipped);
|
|
367
|
+
const rejectedSet = new Set(rejected);
|
|
368
|
+
|
|
369
|
+
const ids = [];
|
|
370
|
+
for (const p of patterns) {
|
|
371
|
+
if (!p || !isValidPatternId(p.id)) continue;
|
|
372
|
+
if (applied.has(p.id)) continue;
|
|
373
|
+
if (unlearned.has(p.id)) continue;
|
|
374
|
+
if (archived.has(p.id)) continue;
|
|
375
|
+
if (skippedSet.has(p.id)) continue;
|
|
376
|
+
if (rejectedSet.has(p.id)) continue;
|
|
377
|
+
ids.push(p.id);
|
|
378
|
+
}
|
|
379
|
+
ids.sort();
|
|
380
|
+
return { ids, skipped, rejected };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Derive a short, human-readable trigger reason for the board entry. Mirrors
|
|
385
|
+
* F-071's evidence shape: signalType + featureRef.
|
|
386
|
+
* @param {object} pattern - PatternRecord.
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
function triggerReasonFor(pattern) {
|
|
390
|
+
if (!pattern || typeof pattern !== 'object') return 'unknown';
|
|
391
|
+
const ev = pattern.evidence || {};
|
|
392
|
+
const sigType = (typeof ev.signalType === 'string' && ev.signalType.length > 0) ? ev.signalType : 'unknown-signal';
|
|
393
|
+
const fid = (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef))
|
|
394
|
+
? pattern.featureRef
|
|
395
|
+
: null;
|
|
396
|
+
const count = Number.isFinite(Number(ev.count)) ? Number(ev.count) : null;
|
|
397
|
+
const parts = [];
|
|
398
|
+
parts.push(sigType);
|
|
399
|
+
if (fid) parts.push(fid);
|
|
400
|
+
if (count != null) parts.push(`n=${count}`);
|
|
401
|
+
return parts.join(' · ');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Derive a confidence float from the F-072 fitness record. We use the pattern's
|
|
406
|
+
* own `confidence` field when present (LLM stage attaches one between 0..1) and
|
|
407
|
+
* fall back to the layer2 reading when it isn't. Returns 0 when neither is
|
|
408
|
+
* available — the board renderer surfaces this as "0.00".
|
|
409
|
+
*
|
|
410
|
+
* @param {object} pattern - PatternRecord.
|
|
411
|
+
* @param {object|null} fitness - FitnessRecord or null.
|
|
412
|
+
* @returns {number}
|
|
413
|
+
*/
|
|
414
|
+
function confidenceFromFitness(pattern, fitness) {
|
|
415
|
+
if (pattern && typeof pattern.confidence === 'number'
|
|
416
|
+
&& Number.isFinite(pattern.confidence) && pattern.confidence >= 0 && pattern.confidence <= 1) {
|
|
417
|
+
return pattern.confidence;
|
|
418
|
+
}
|
|
419
|
+
if (fitness && fitness.layer2 && typeof fitness.layer2.value === 'number' && Number.isFinite(fitness.layer2.value)) {
|
|
420
|
+
// Layer-2 value range is open (memoryRefs + 2*regrets / n); clamp to 0..1
|
|
421
|
+
// for a confidence reading. A "1.0" Layer-2 average means every active
|
|
422
|
+
// session produced at least one strong positive — solid confidence.
|
|
423
|
+
const v = fitness.layer2.value;
|
|
424
|
+
if (v < 0) return 0;
|
|
425
|
+
if (v > 1) return 1;
|
|
426
|
+
return v;
|
|
427
|
+
}
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// -----------------------------------------------------------------------------
|
|
432
|
+
// Public API — buildReviewBoard / shouldShowBoard
|
|
433
|
+
// -----------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
// @cap-todo(ac:F-073/AC-1) Pending = persisted ∧ ¬applied ∧ ¬unlearned ∧ ¬archived
|
|
436
|
+
// ∧ ¬skipped-this-session ∧ ¬rejected-this-session.
|
|
437
|
+
// @cap-todo(ac:F-073/AC-6) Each eligible pattern surfaces options Approve / Reject / Skip,
|
|
438
|
+
// plus Unlearn (with 'Rückzug empfohlen' label) when the id is in
|
|
439
|
+
// listRetractRecommended().
|
|
440
|
+
/**
|
|
441
|
+
* Build the in-memory review board. Pure-compute except for the archive sweep
|
|
442
|
+
* (the only mutation): we do NOT write board.md here — the orchestrator calls
|
|
443
|
+
* renderBoardMarkdown + writeBoardFile separately so dry-run tests stay clean.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} projectRoot
|
|
446
|
+
* @param {Object} [options]
|
|
447
|
+
* @param {string} [options.sessionId] - Override the SESSION.json sessionId.
|
|
448
|
+
* @param {Date|string} [options.now] - Override timestamp (mostly for tests).
|
|
449
|
+
* @returns {ReviewBoard}
|
|
450
|
+
*/
|
|
451
|
+
function buildReviewBoard(projectRoot, options) {
|
|
452
|
+
const opts = options || {};
|
|
453
|
+
const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
454
|
+
const sid = opts.sessionId !== undefined
|
|
455
|
+
? sanitiseSessionId(opts.sessionId)
|
|
456
|
+
: currentSessionId(projectRoot);
|
|
457
|
+
|
|
458
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
459
|
+
return {
|
|
460
|
+
eligible: [],
|
|
461
|
+
threshold: { met: false, reason: 'invalid-project-root' },
|
|
462
|
+
archived: [],
|
|
463
|
+
skippedThisSession: [],
|
|
464
|
+
rejectedThisSession: [],
|
|
465
|
+
sessionId: sid,
|
|
466
|
+
ts,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const { ids, skipped, rejected } = eligiblePatternIds(projectRoot, sid);
|
|
471
|
+
|
|
472
|
+
// Build per-pattern entries with fitness + confidence + retract status.
|
|
473
|
+
let retractList = [];
|
|
474
|
+
try { retractList = patternApply.listRetractRecommended(projectRoot) || []; } catch (_e) { retractList = []; }
|
|
475
|
+
const retractSet = new Set(retractList);
|
|
476
|
+
|
|
477
|
+
// Read patterns once (listPatterns is the single source of truth).
|
|
478
|
+
let allPatterns = [];
|
|
479
|
+
try { allPatterns = patternPipeline.listPatterns(projectRoot) || []; } catch (_e) { allPatterns = []; }
|
|
480
|
+
/** @type {Map<string, object>} */
|
|
481
|
+
const byId = new Map();
|
|
482
|
+
for (const p of allPatterns) {
|
|
483
|
+
if (p && isValidPatternId(p.id)) byId.set(p.id, p);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** @type {EligibleEntry[]} */
|
|
487
|
+
const eligible = [];
|
|
488
|
+
for (const id of ids) {
|
|
489
|
+
const pattern = byId.get(id);
|
|
490
|
+
if (!pattern) continue; // race: pattern deleted between listPatterns and now
|
|
491
|
+
let fitness = null;
|
|
492
|
+
try { fitness = fitnessScore.getFitness(projectRoot, id); } catch (_e) { fitness = null; }
|
|
493
|
+
const confidence = confidenceFromFitness(pattern, fitness);
|
|
494
|
+
const retractRecommended = retractSet.has(id);
|
|
495
|
+
const opts2 = ['Approve', 'Reject', 'Skip'];
|
|
496
|
+
if (retractRecommended) opts2.push('Unlearn');
|
|
497
|
+
eligible.push({
|
|
498
|
+
patternId: id,
|
|
499
|
+
fitness,
|
|
500
|
+
confidence,
|
|
501
|
+
triggerReason: triggerReasonFor(pattern),
|
|
502
|
+
retractRecommended,
|
|
503
|
+
options: opts2,
|
|
504
|
+
pattern, // for the renderer; the JSON shape exposed externally still includes it
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const threshold = computeThreshold(eligible);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
eligible,
|
|
512
|
+
threshold,
|
|
513
|
+
archived: [], // populated by archiveStalePatterns separately
|
|
514
|
+
skippedThisSession: skipped,
|
|
515
|
+
rejectedThisSession: rejected,
|
|
516
|
+
sessionId: sid,
|
|
517
|
+
ts,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Compute the AC-2 threshold from an eligible-entries list. Pure-compute helper.
|
|
523
|
+
* Public-ish via shouldShowBoard, which re-uses this function on a freshly-built
|
|
524
|
+
* board.
|
|
525
|
+
*
|
|
526
|
+
* @cap-todo(ac:F-073/AC-2) Board appears only when ≥1 high-confidence (layer2.ready
|
|
527
|
+
* AND value≥0.75 AND n≥5) OR ≥3 candidates of any kind.
|
|
528
|
+
*
|
|
529
|
+
* @param {EligibleEntry[]} eligible
|
|
530
|
+
* @returns {{met: boolean, reason: string}}
|
|
531
|
+
*/
|
|
532
|
+
function computeThreshold(eligible) {
|
|
533
|
+
if (!Array.isArray(eligible) || eligible.length === 0) {
|
|
534
|
+
return { met: false, reason: 'no-eligible-patterns' };
|
|
535
|
+
}
|
|
536
|
+
let highConfidenceCount = 0;
|
|
537
|
+
for (const e of eligible) {
|
|
538
|
+
if (!e || !e.fitness || !e.fitness.layer2) continue;
|
|
539
|
+
const l2 = e.fitness.layer2;
|
|
540
|
+
if (l2.ready === true
|
|
541
|
+
&& Number(l2.value) >= HIGH_CONFIDENCE_LAYER2_VALUE
|
|
542
|
+
&& Number(l2.n) >= HIGH_CONFIDENCE_LAYER2_N) {
|
|
543
|
+
highConfidenceCount += 1;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (highConfidenceCount >= 1) {
|
|
547
|
+
return {
|
|
548
|
+
met: true,
|
|
549
|
+
reason: `high-confidence-pattern (${highConfidenceCount} eligible with layer2.value>=${HIGH_CONFIDENCE_LAYER2_VALUE} n>=${HIGH_CONFIDENCE_LAYER2_N})`,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (eligible.length >= ANY_KIND_THRESHOLD) {
|
|
553
|
+
return {
|
|
554
|
+
met: true,
|
|
555
|
+
reason: `any-kind-threshold (${eligible.length} eligible patterns >= ${ANY_KIND_THRESHOLD})`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
met: false,
|
|
560
|
+
reason: `below-threshold (${eligible.length} eligible, ${highConfidenceCount} high-confidence)`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The AC-2 gate, also used by the Stop-hook. Compute-only, no side-effects.
|
|
566
|
+
* Mirrors buildReviewBoard's eligibility pipeline but skips the per-entry
|
|
567
|
+
* fitness lookup unless we need it (we DO need it for the high-confidence arm).
|
|
568
|
+
*
|
|
569
|
+
* @cap-todo(ac:F-073/AC-2) shouldShowBoard returns the boolean gate.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} projectRoot
|
|
572
|
+
* @param {Object} [options]
|
|
573
|
+
* @param {string} [options.sessionId]
|
|
574
|
+
* @returns {boolean}
|
|
575
|
+
*/
|
|
576
|
+
function shouldShowBoard(projectRoot, options) {
|
|
577
|
+
const board = buildReviewBoard(projectRoot, options);
|
|
578
|
+
return board.threshold.met === true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// -----------------------------------------------------------------------------
|
|
582
|
+
// Public API — renderBoardMarkdown / writeBoardFile
|
|
583
|
+
// -----------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Render the board.md content from a ReviewBoard object. PURE-compute string
|
|
587
|
+
* builder. Renderer escapes markdown control characters in dynamic fields so
|
|
588
|
+
* a hostile pattern record can't smuggle markdown injection.
|
|
589
|
+
*
|
|
590
|
+
* @cap-risk(F-073/AC-3) Renderer escapes the dynamic fields (triggerReason,
|
|
591
|
+
* featureRef, sessionId) by collapsing newlines and
|
|
592
|
+
* backticks into literal placeholders. F-071 already
|
|
593
|
+
* constrains pattern fields, but this is defence in
|
|
594
|
+
* depth — a future contributor adding a free-text
|
|
595
|
+
* field shouldn't have to remember to escape on render.
|
|
596
|
+
*
|
|
597
|
+
* @param {ReviewBoard} board
|
|
598
|
+
* @returns {string}
|
|
599
|
+
*/
|
|
600
|
+
function renderBoardMarkdown(board) {
|
|
601
|
+
if (!board || typeof board !== 'object') return '';
|
|
602
|
+
const lines = [];
|
|
603
|
+
const ts = typeof board.ts === 'string' ? board.ts : new Date().toISOString();
|
|
604
|
+
lines.push(`# Pattern Review Board — ${escapeMd(ts)}`);
|
|
605
|
+
lines.push('');
|
|
606
|
+
if (board.sessionId) {
|
|
607
|
+
lines.push(`Session: \`${escapeMd(board.sessionId)}\``);
|
|
608
|
+
}
|
|
609
|
+
lines.push(`Threshold: ${board.threshold.met ? 'MET' : 'BELOW'} (${escapeMd(board.threshold.reason || '')})`);
|
|
610
|
+
lines.push('');
|
|
611
|
+
|
|
612
|
+
if (!Array.isArray(board.eligible) || board.eligible.length === 0) {
|
|
613
|
+
lines.push('_(no eligible patterns)_');
|
|
614
|
+
lines.push('');
|
|
615
|
+
return lines.join('\n');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
lines.push(`Eligible patterns: ${board.eligible.length}`);
|
|
619
|
+
lines.push('');
|
|
620
|
+
lines.push('---');
|
|
621
|
+
lines.push('');
|
|
622
|
+
|
|
623
|
+
for (const e of board.eligible) {
|
|
624
|
+
const p = e.pattern || {};
|
|
625
|
+
const ev = p.evidence || {};
|
|
626
|
+
const fitness = e.fitness;
|
|
627
|
+
|
|
628
|
+
lines.push(`## ${escapeMd(e.patternId)} — ${escapeMd(e.triggerReason || 'unknown')}`);
|
|
629
|
+
lines.push('');
|
|
630
|
+
lines.push(`- **Level**: ${escapeMd(p.level || 'unknown')}`);
|
|
631
|
+
lines.push(`- **Feature**: ${escapeMd(p.featureRef || '(unassigned)')}`);
|
|
632
|
+
if (fitness && fitness.layer1 && fitness.layer2) {
|
|
633
|
+
const l1v = Number(fitness.layer1.value);
|
|
634
|
+
const l2v = Number(fitness.layer2.value);
|
|
635
|
+
const l2n = Number(fitness.layer2.n);
|
|
636
|
+
const ready = fitness.layer2.ready === true;
|
|
637
|
+
lines.push(`- **Fitness**: layer1=${Number.isFinite(l1v) ? l1v : 0}, layer2=${formatFloat(l2v)} (n=${Number.isFinite(l2n) ? l2n : 0}, ready=${ready})`);
|
|
638
|
+
} else {
|
|
639
|
+
lines.push('- **Fitness**: _(no fitness record)_');
|
|
640
|
+
}
|
|
641
|
+
lines.push(`- **Confidence**: ${formatFloat(e.confidence)}`);
|
|
642
|
+
const source = (p.source === 'llm' || p.source === 'heuristic') ? p.source : 'unknown';
|
|
643
|
+
const degraded = p.degraded === true ? 'yes' : 'no';
|
|
644
|
+
lines.push(`- **Source**: ${escapeMd(source)} | Degraded: ${degraded}`);
|
|
645
|
+
if (e.retractRecommended) {
|
|
646
|
+
lines.push('- **⚠️ Rückzug empfohlen** (current vs snapshot delta worsened, see retract-recommendations.jsonl)');
|
|
647
|
+
}
|
|
648
|
+
if (ev && typeof ev.candidateId === 'string' && /^[0-9a-f]+$/.test(ev.candidateId)) {
|
|
649
|
+
lines.push(`- **Evidence candidateId**: \`${escapeMd(ev.candidateId)}\``);
|
|
650
|
+
}
|
|
651
|
+
lines.push('');
|
|
652
|
+
lines.push(`**Options**: ${e.options.join(' / ')}`);
|
|
653
|
+
lines.push('');
|
|
654
|
+
lines.push('---');
|
|
655
|
+
lines.push('');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Hand-off contract for the outer agent (D2). Documents the AC-7 exit-code
|
|
659
|
+
// semantics so the agent reports back faithfully.
|
|
660
|
+
lines.push('## Hand-off');
|
|
661
|
+
lines.push('');
|
|
662
|
+
lines.push('For each pattern above, choose ONE of approve / reject / skip / unlearn:');
|
|
663
|
+
lines.push('- **approve** → call `cap-pattern-apply.applyPattern(projectRoot, patternId)`. Record the commit hash.');
|
|
664
|
+
lines.push('- **unlearn** → call `cap-pattern-apply.unlearnPattern(projectRoot, patternId, { reason: \'manual\' })`.');
|
|
665
|
+
lines.push('- **skip** → call `cap-learn-review.skipPattern(projectRoot, patternId)`. Per-session only.');
|
|
666
|
+
lines.push('- **reject** → call `cap-learn-review.rejectPattern(projectRoot, patternId)`. Per-session only.');
|
|
667
|
+
lines.push('');
|
|
668
|
+
lines.push('**Exit code contract (F-073/AC-7)**: the skill exits 0 ONLY when EVERY approve produced `applied:true`.');
|
|
669
|
+
lines.push('Any apply returning `applied:false` → non-zero exit + a description of the failure. Do not swallow.');
|
|
670
|
+
lines.push('');
|
|
671
|
+
lines.push('Privacy: this board contains structured metadata only — counts, hashes, ids. No raw paths or user text.');
|
|
672
|
+
lines.push('');
|
|
673
|
+
|
|
674
|
+
return lines.join('\n');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Format a float to 2 decimal places. Defensive: NaN/non-finite collapses to '0.00'.
|
|
679
|
+
* @param {number} v
|
|
680
|
+
* @returns {string}
|
|
681
|
+
*/
|
|
682
|
+
function formatFloat(v) {
|
|
683
|
+
const n = Number(v);
|
|
684
|
+
if (!Number.isFinite(n)) return '0.00';
|
|
685
|
+
return n.toFixed(2);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Escape markdown control characters in a single-line dynamic field. We:
|
|
690
|
+
* - Collapse all whitespace runs (incl. newlines) to a single space.
|
|
691
|
+
* - Replace backticks with single quotes (prevents code-fence escapes).
|
|
692
|
+
* - Drop the markdown structural triplet '---' if it appears bare (a section
|
|
693
|
+
* break inside an inline header would scramble the renderer's output).
|
|
694
|
+
* @param {any} v
|
|
695
|
+
* @returns {string}
|
|
696
|
+
*/
|
|
697
|
+
function escapeMd(v) {
|
|
698
|
+
if (v === null || v === undefined) return '';
|
|
699
|
+
let s = String(v);
|
|
700
|
+
s = s.replace(/`/g, "'");
|
|
701
|
+
s = s.replace(/[\r\n\t\f\v]+/g, ' ');
|
|
702
|
+
s = s.replace(/\s{2,}/g, ' ');
|
|
703
|
+
// Defensive: collapse a literal '---' run (markdown thematic break) so it
|
|
704
|
+
// can't terminate a list item early. Three or more consecutive '-' dashes
|
|
705
|
+
// in the middle of an inline field get a thin space between them.
|
|
706
|
+
s = s.replace(/-{3,}/g, '—');
|
|
707
|
+
return s;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Atomic write of board.md.
|
|
712
|
+
* @param {string} projectRoot
|
|
713
|
+
* @param {string} boardMd
|
|
714
|
+
* @returns {boolean}
|
|
715
|
+
*/
|
|
716
|
+
function writeBoardFile(projectRoot, boardMd) {
|
|
717
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
718
|
+
if (typeof boardMd !== 'string') return false;
|
|
719
|
+
ensureDir(learningRoot(projectRoot));
|
|
720
|
+
return writeAtomic(boardFilePath(projectRoot), boardMd);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// -----------------------------------------------------------------------------
|
|
724
|
+
// Public API — skipPattern / rejectPattern (AC-4)
|
|
725
|
+
// -----------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
// @cap-todo(ac:F-073/AC-4) Skip persists to .cap/learning/skipped-<sessionId>.json.
|
|
728
|
+
// Per-session ONLY. New session shows the patterns again.
|
|
729
|
+
/**
|
|
730
|
+
* Append a patternId to the session's skipped file. Idempotent: re-adding an
|
|
731
|
+
* already-present id does NOT duplicate the entry.
|
|
732
|
+
*
|
|
733
|
+
* @param {string} projectRoot
|
|
734
|
+
* @param {string} patternId
|
|
735
|
+
* @param {string} [sessionId] - Override the SESSION.json sessionId.
|
|
736
|
+
* @returns {boolean}
|
|
737
|
+
*/
|
|
738
|
+
function skipPattern(projectRoot, patternId, sessionId) {
|
|
739
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
740
|
+
if (!isValidPatternId(patternId)) return false;
|
|
741
|
+
const sid = sessionId !== undefined ? sanitiseSessionId(sessionId) : currentSessionId(projectRoot);
|
|
742
|
+
if (!sid) return false;
|
|
743
|
+
|
|
744
|
+
const fp = skippedFilePath(projectRoot, sid);
|
|
745
|
+
// Read existing first so the file shape is consistent (ids de-duped, sorted).
|
|
746
|
+
const prior = readJson(fp);
|
|
747
|
+
/** @type {Set<string>} */
|
|
748
|
+
const ids = new Set();
|
|
749
|
+
if (prior && Array.isArray(prior.patternIds)) {
|
|
750
|
+
for (const id of prior.patternIds) {
|
|
751
|
+
if (isValidPatternId(id)) ids.add(id);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// @cap-decision(F-073/D7) True idempotency — when the patternId is already recorded,
|
|
755
|
+
// skip the write entirely so the on-disk ts does not change. Otherwise
|
|
756
|
+
// a second skipPattern bumps `ts` by 1 ms and the file is no longer
|
|
757
|
+
// byte-stable, polluting `git diff` and breaking the "no side-effect"
|
|
758
|
+
// contract reviewers expect from idempotent helpers.
|
|
759
|
+
if (ids.has(patternId)) return true;
|
|
760
|
+
|
|
761
|
+
ids.add(patternId);
|
|
762
|
+
const sorted = [...ids].sort();
|
|
763
|
+
return writeAtomicJson(fp, {
|
|
764
|
+
sessionId: sid,
|
|
765
|
+
ts: new Date().toISOString(),
|
|
766
|
+
patternIds: sorted,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Append a patternId to the session's rejected file. Idempotent.
|
|
772
|
+
* @param {string} projectRoot
|
|
773
|
+
* @param {string} patternId
|
|
774
|
+
* @param {string} [sessionId]
|
|
775
|
+
* @returns {boolean}
|
|
776
|
+
*/
|
|
777
|
+
function rejectPattern(projectRoot, patternId, sessionId) {
|
|
778
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
779
|
+
if (!isValidPatternId(patternId)) return false;
|
|
780
|
+
const sid = sessionId !== undefined ? sanitiseSessionId(sessionId) : currentSessionId(projectRoot);
|
|
781
|
+
if (!sid) return false;
|
|
782
|
+
|
|
783
|
+
const fp = rejectedFilePath(projectRoot, sid);
|
|
784
|
+
const prior = readJson(fp);
|
|
785
|
+
/** @type {Set<string>} */
|
|
786
|
+
const ids = new Set();
|
|
787
|
+
if (prior && Array.isArray(prior.patternIds)) {
|
|
788
|
+
for (const id of prior.patternIds) {
|
|
789
|
+
if (isValidPatternId(id)) ids.add(id);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// @cap-decision(F-073/D7) Idempotency mirror of skipPattern — no write when the id is already recorded.
|
|
793
|
+
if (ids.has(patternId)) return true;
|
|
794
|
+
|
|
795
|
+
ids.add(patternId);
|
|
796
|
+
const sorted = [...ids].sort();
|
|
797
|
+
return writeAtomicJson(fp, {
|
|
798
|
+
sessionId: sid,
|
|
799
|
+
ts: new Date().toISOString(),
|
|
800
|
+
patternIds: sorted,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// -----------------------------------------------------------------------------
|
|
805
|
+
// Public API — archiveStalePatterns (AC-5)
|
|
806
|
+
// -----------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
// @cap-todo(ac:F-073/AC-5) Patterns un-reviewed > 7 sessions auto-move to
|
|
809
|
+
// .cap/learning/archive/<P-NNN>.json AND are removed
|
|
810
|
+
// from .cap/learning/patterns/. Insufficient-history
|
|
811
|
+
// short-circuit when the corpus has fewer than 7
|
|
812
|
+
// distinct sessions total.
|
|
813
|
+
/**
|
|
814
|
+
* Compute the count of distinct sessionIds in the F-070 corpus across the three
|
|
815
|
+
* signal types. F-072 has unionSessionsByRecency but doesn't export it; we
|
|
816
|
+
* replicate the simple distinct-count here. We intentionally do NOT include
|
|
817
|
+
* SESSION.json's sessionId (that's a single in-progress session, not a corpus
|
|
818
|
+
* record).
|
|
819
|
+
*
|
|
820
|
+
* @cap-decision(F-073/D5) Replicating the union-of-distinct-sessionIds count
|
|
821
|
+
* inline because F-072 doesn't export the helper. The cost is
|
|
822
|
+
* ~5 lines of code; the benefit is no API surface change to
|
|
823
|
+
* F-072 just to wire F-073.
|
|
824
|
+
*
|
|
825
|
+
* @param {string} projectRoot
|
|
826
|
+
* @returns {{ corpusSessionCount: number, sessionsByPattern: Map<string, Set<string>> }}
|
|
827
|
+
*/
|
|
828
|
+
function corpusSessionStats(projectRoot) {
|
|
829
|
+
let overrides = [];
|
|
830
|
+
let memoryRefs = [];
|
|
831
|
+
let regrets = [];
|
|
832
|
+
try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
|
|
833
|
+
try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
|
|
834
|
+
try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
|
|
835
|
+
|
|
836
|
+
/** @type {Set<string>} */
|
|
837
|
+
const all = new Set();
|
|
838
|
+
for (const arr of [overrides, memoryRefs, regrets]) {
|
|
839
|
+
for (const r of arr) {
|
|
840
|
+
if (r && typeof r.sessionId === 'string' && r.sessionId.length > 0) {
|
|
841
|
+
all.add(r.sessionId);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Per-record { sessionId, ts } collection — used by callers to count
|
|
847
|
+
// per-pattern session reach since pattern.createdAt.
|
|
848
|
+
/** @type {Map<string, Set<string>>} */
|
|
849
|
+
const sessionsByPattern = new Map(); // populated lazily by archiveStalePatterns
|
|
850
|
+
return { corpusSessionCount: all.size, sessionsByPattern, allRecords: { overrides, memoryRefs, regrets } };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Archive any pattern whose distinct-session count since createdAt exceeds
|
|
855
|
+
* STALE_SESSION_THRESHOLD. Idempotent: an already-archived pattern is skipped.
|
|
856
|
+
* Insufficient-history short-circuit: when corpus has fewer than the threshold
|
|
857
|
+
* sessions total, NO archive (we don't have enough data).
|
|
858
|
+
*
|
|
859
|
+
* Excludes:
|
|
860
|
+
* - applied / unlearned patterns (already left review).
|
|
861
|
+
* - patterns skipped or rejected this session (the user is engaged with them).
|
|
862
|
+
*
|
|
863
|
+
* @param {string} projectRoot
|
|
864
|
+
* @param {Object} [options]
|
|
865
|
+
* @param {string} [options.sessionId] - Override SESSION.json sessionId.
|
|
866
|
+
* @param {Date|string} [options.now] - Override timestamp on the archived record.
|
|
867
|
+
* @param {number} [options.window] - Override STALE_SESSION_THRESHOLD (mostly for tests).
|
|
868
|
+
* @returns {{ archived: string[], errors: string[] }}
|
|
869
|
+
*/
|
|
870
|
+
function archiveStalePatterns(projectRoot, options) {
|
|
871
|
+
const opts = options || {};
|
|
872
|
+
const archived = [];
|
|
873
|
+
const errors = [];
|
|
874
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
875
|
+
return { archived, errors: ['invalid-project-root'] };
|
|
876
|
+
}
|
|
877
|
+
const window = (typeof opts.window === 'number' && opts.window > 0) ? opts.window : STALE_SESSION_THRESHOLD;
|
|
878
|
+
const sid = opts.sessionId !== undefined ? sanitiseSessionId(opts.sessionId) : currentSessionId(projectRoot);
|
|
879
|
+
const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
880
|
+
|
|
881
|
+
const stats = corpusSessionStats(projectRoot);
|
|
882
|
+
if (stats.corpusSessionCount < window) {
|
|
883
|
+
// @cap-decision(F-073/D5) Insufficient-history short-circuit. Mirrors F-072's
|
|
884
|
+
// expiry-window guard.
|
|
885
|
+
return { archived, errors };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Read patterns + state via module APIs.
|
|
889
|
+
let patterns = [];
|
|
890
|
+
try { patterns = patternPipeline.listPatterns(projectRoot) || []; } catch (e) {
|
|
891
|
+
errors.push(`listPatterns failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
892
|
+
return { archived, errors };
|
|
893
|
+
}
|
|
894
|
+
const applied = new Set((patternApply.listAppliedPatterns(projectRoot) || [])
|
|
895
|
+
.map((a) => a && a.patternId).filter(isValidPatternId));
|
|
896
|
+
const unlearned = new Set((patternApply.listUnlearnedPatterns(projectRoot) || [])
|
|
897
|
+
.map((u) => u && u.patternId).filter(isValidPatternId));
|
|
898
|
+
const skipped = sid ? new Set(loadSkippedThisSession(projectRoot, sid)) : new Set();
|
|
899
|
+
const rejected = sid ? new Set(loadRejectedThisSession(projectRoot, sid)) : new Set();
|
|
900
|
+
|
|
901
|
+
for (const pattern of patterns) {
|
|
902
|
+
if (!pattern || !isValidPatternId(pattern.id)) continue;
|
|
903
|
+
if (applied.has(pattern.id)) continue;
|
|
904
|
+
if (unlearned.has(pattern.id)) continue;
|
|
905
|
+
if (skipped.has(pattern.id)) continue;
|
|
906
|
+
if (rejected.has(pattern.id)) continue;
|
|
907
|
+
|
|
908
|
+
const since = typeof pattern.createdAt === 'string' ? pattern.createdAt : null;
|
|
909
|
+
if (!since) continue; // can't compute session-reach without a createdAt
|
|
910
|
+
|
|
911
|
+
// Already archived? (idempotency)
|
|
912
|
+
const archivePath = archiveFilePath(projectRoot, pattern.id);
|
|
913
|
+
if (fs.existsSync(archivePath)) {
|
|
914
|
+
// The source pattern file might still exist if a prior archive only wrote
|
|
915
|
+
// the archive copy and crashed before delete; clean that up here.
|
|
916
|
+
const sourcePath = patternFilePath(projectRoot, pattern.id);
|
|
917
|
+
if (fs.existsSync(sourcePath)) {
|
|
918
|
+
try { fs.unlinkSync(sourcePath); } catch (_e) { /* ignore */ }
|
|
919
|
+
}
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Distinct sessions since createdAt across union of three corpora.
|
|
924
|
+
const sessionsSet = new Set();
|
|
925
|
+
for (const arrName of ['overrides', 'memoryRefs', 'regrets']) {
|
|
926
|
+
for (const r of stats.allRecords[arrName] || []) {
|
|
927
|
+
if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
|
|
928
|
+
if (typeof r.ts !== 'string') continue;
|
|
929
|
+
if (r.ts < since) continue;
|
|
930
|
+
sessionsSet.add(r.sessionId);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (sessionsSet.size <= window) continue; // not stale yet — needs MORE than threshold
|
|
934
|
+
|
|
935
|
+
// Move: write archive record (with archivedAt + reason) atomically, then
|
|
936
|
+
// delete the source pattern file.
|
|
937
|
+
const record = {
|
|
938
|
+
...pattern,
|
|
939
|
+
archivedAt: nowIso,
|
|
940
|
+
reason: 'stale-7-sessions',
|
|
941
|
+
};
|
|
942
|
+
if (!writeAtomicJson(archivePath, record)) {
|
|
943
|
+
errors.push(`archive write failed for ${pattern.id}`);
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
const sourcePath = patternFilePath(projectRoot, pattern.id);
|
|
947
|
+
try {
|
|
948
|
+
if (fs.existsSync(sourcePath)) fs.unlinkSync(sourcePath);
|
|
949
|
+
} catch (e) {
|
|
950
|
+
errors.push(`archive source delete failed for ${pattern.id}: ${e && e.message ? e.message : 'unknown'}`);
|
|
951
|
+
// Don't include in archived list — partially-applied move.
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
archived.push(pattern.id);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
archived.sort();
|
|
958
|
+
return { archived, errors };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// -----------------------------------------------------------------------------
|
|
962
|
+
// Public API — board-pending.flag round-trip (AC-3)
|
|
963
|
+
// -----------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
// @cap-todo(ac:F-073/AC-3) Stop-hook computes shouldShowBoard() and writes the
|
|
966
|
+
// .flag file when true. /cap:status / /cap:learn review
|
|
967
|
+
// surface the flag. Skill clears the flag after the
|
|
968
|
+
// board has been processed.
|
|
969
|
+
/**
|
|
970
|
+
* Write the board-pending flag. The flag content is a tiny JSON snippet
|
|
971
|
+
* (timestamp + sessionId + eligibleCount) for diagnostic purposes; the SKILL
|
|
972
|
+
* checks for FILE EXISTENCE, not content, so a half-written flag is harmless.
|
|
973
|
+
*
|
|
974
|
+
* @cap-risk(F-073/AC-3) sessionId is sanitised before persistence so a hostile
|
|
975
|
+
* SESSION.json can't smuggle bytes via the flag content.
|
|
976
|
+
* Adversarial test pins this with a SECRET_NEEDLE
|
|
977
|
+
* sessionId.
|
|
978
|
+
*
|
|
979
|
+
* @param {string} projectRoot
|
|
980
|
+
* @param {Object} [options]
|
|
981
|
+
* @param {string} [options.sessionId]
|
|
982
|
+
* @param {number} [options.eligibleCount]
|
|
983
|
+
* @param {Date|string} [options.now]
|
|
984
|
+
* @returns {boolean}
|
|
985
|
+
*/
|
|
986
|
+
function writeBoardPendingFlag(projectRoot, options) {
|
|
987
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
988
|
+
const opts = options || {};
|
|
989
|
+
const sid = opts.sessionId !== undefined ? sanitiseSessionId(opts.sessionId) : currentSessionId(projectRoot);
|
|
990
|
+
const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
|
|
991
|
+
const eligibleCount = Number.isFinite(Number(opts.eligibleCount)) ? Math.max(0, Math.floor(Number(opts.eligibleCount))) : 0;
|
|
992
|
+
ensureDir(learningRoot(projectRoot));
|
|
993
|
+
const payload = { ts, sessionId: sid || null, eligibleCount };
|
|
994
|
+
return writeAtomicJson(boardPendingFlagPath(projectRoot), payload);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Remove the board-pending flag. Idempotent: missing file is success.
|
|
999
|
+
* @param {string} projectRoot
|
|
1000
|
+
* @returns {boolean}
|
|
1001
|
+
*/
|
|
1002
|
+
function clearBoardPendingFlag(projectRoot) {
|
|
1003
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
1004
|
+
const fp = boardPendingFlagPath(projectRoot);
|
|
1005
|
+
try {
|
|
1006
|
+
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
1007
|
+
return true;
|
|
1008
|
+
} catch (_e) {
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Return true iff the board-pending flag exists. Used by /cap:status and the
|
|
1015
|
+
* skill startup banner.
|
|
1016
|
+
* @param {string} projectRoot
|
|
1017
|
+
* @returns {boolean}
|
|
1018
|
+
*/
|
|
1019
|
+
function hasBoardPendingFlag(projectRoot) {
|
|
1020
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
|
|
1021
|
+
try {
|
|
1022
|
+
return fs.existsSync(boardPendingFlagPath(projectRoot));
|
|
1023
|
+
} catch (_e) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// -----------------------------------------------------------------------------
|
|
1029
|
+
// Exports — keep this list minimal. /cap:learn review + the Stop hook should
|
|
1030
|
+
// consume only these.
|
|
1031
|
+
// -----------------------------------------------------------------------------
|
|
1032
|
+
|
|
1033
|
+
module.exports = {
|
|
1034
|
+
// Constants — exported for tests + downstream consumers.
|
|
1035
|
+
CAP_DIR,
|
|
1036
|
+
LEARNING_DIR,
|
|
1037
|
+
PATTERNS_DIR,
|
|
1038
|
+
ARCHIVE_DIR,
|
|
1039
|
+
BOARD_FILE,
|
|
1040
|
+
BOARD_PENDING_FLAG,
|
|
1041
|
+
HIGH_CONFIDENCE_LAYER2_VALUE,
|
|
1042
|
+
HIGH_CONFIDENCE_LAYER2_N,
|
|
1043
|
+
ANY_KIND_THRESHOLD,
|
|
1044
|
+
STALE_SESSION_THRESHOLD,
|
|
1045
|
+
// Public API.
|
|
1046
|
+
buildReviewBoard,
|
|
1047
|
+
renderBoardMarkdown,
|
|
1048
|
+
writeBoardFile,
|
|
1049
|
+
skipPattern,
|
|
1050
|
+
rejectPattern,
|
|
1051
|
+
archiveStalePatterns,
|
|
1052
|
+
shouldShowBoard,
|
|
1053
|
+
writeBoardPendingFlag,
|
|
1054
|
+
clearBoardPendingFlag,
|
|
1055
|
+
hasBoardPendingFlag,
|
|
1056
|
+
// Path helpers — exported for tests.
|
|
1057
|
+
archiveDir,
|
|
1058
|
+
archiveFilePath,
|
|
1059
|
+
boardFilePath,
|
|
1060
|
+
boardPendingFlagPath,
|
|
1061
|
+
skippedFilePath,
|
|
1062
|
+
rejectedFilePath,
|
|
1063
|
+
// Helpers exposed for tests / introspection.
|
|
1064
|
+
loadSkippedThisSession,
|
|
1065
|
+
loadRejectedThisSession,
|
|
1066
|
+
eligiblePatternIds,
|
|
1067
|
+
triggerReasonFor,
|
|
1068
|
+
confidenceFromFitness,
|
|
1069
|
+
computeThreshold,
|
|
1070
|
+
currentSessionId,
|
|
1071
|
+
sanitiseSessionId,
|
|
1072
|
+
};
|