pan-wizard 3.5.1 → 3.7.10

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.
Files changed (93) hide show
  1. package/README.md +10 -10
  2. package/agents/pan-executor.md +18 -0
  3. package/agents/pan-experiment-runner.md +126 -0
  4. package/agents/pan-phase-researcher.md +16 -0
  5. package/agents/pan-plan-checker.md +80 -0
  6. package/agents/pan-planner.md +19 -0
  7. package/agents/pan-reviewer.md +2 -0
  8. package/agents/pan-verifier.md +41 -0
  9. package/bin/install-lib.cjs +55 -0
  10. package/bin/install.js +71 -22
  11. package/commands/pan/debug.md +1 -1
  12. package/commands/pan/experiment.md +219 -0
  13. package/commands/pan/health.md +1 -1
  14. package/commands/pan/learn.md +15 -1
  15. package/commands/pan/optimize.md +13 -0
  16. package/commands/pan/patches.md +10 -1
  17. package/commands/pan/phase-tests.md +1 -4
  18. package/commands/pan/todo-add.md +1 -1
  19. package/commands/pan/todo-check.md +1 -1
  20. package/hooks/dist/pan-cost-logger.js +54 -4
  21. package/hooks/dist/pan-trace-logger.js +72 -3
  22. package/package.json +67 -66
  23. package/pan-wizard-core/bin/lib/commands.cjs +8 -0
  24. package/pan-wizard-core/bin/lib/config.cjs +13 -2
  25. package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
  26. package/pan-wizard-core/bin/lib/core.cjs +13 -0
  27. package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
  28. package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
  29. package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
  30. package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
  31. package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
  32. package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
  33. package/pan-wizard-core/bin/lib/experiment.cjs +501 -0
  34. package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
  35. package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
  36. package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
  37. package/pan-wizard-core/bin/lib/runner.cjs +472 -0
  38. package/pan-wizard-core/bin/pan-tools.cjs +222 -2
  39. package/pan-wizard-core/learnings/README.md +70 -0
  40. package/pan-wizard-core/learnings/index.json +540 -0
  41. package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
  42. package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
  43. package/pan-wizard-core/learnings/internal/external-research.md +93 -0
  44. package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
  45. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
  46. package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
  47. package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
  48. package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
  49. package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
  50. package/pan-wizard-core/learnings/universal/composition.md +33 -0
  51. package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
  52. package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
  53. package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
  54. package/pan-wizard-core/learnings/universal/design-process.md +21 -0
  55. package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
  56. package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
  57. package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
  58. package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
  59. package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
  60. package/pan-wizard-core/learnings/universal/invariants.md +21 -0
  61. package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
  62. package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
  63. package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
  64. package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
  65. package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
  66. package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
  67. package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
  68. package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
  69. package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
  70. package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
  71. package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
  72. package/pan-wizard-core/learnings/universal/unicode.md +21 -0
  73. package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
  74. package/pan-wizard-core/references/guardrails.md +58 -0
  75. package/pan-wizard-core/references/handoff-decisions.md +156 -0
  76. package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
  77. package/pan-wizard-core/references/verification-patterns.md +31 -0
  78. package/pan-wizard-core/templates/config.json +2 -1
  79. package/pan-wizard-core/templates/idea.md +52 -0
  80. package/pan-wizard-core/templates/summary-complex.md +14 -5
  81. package/pan-wizard-core/templates/summary-minimal.md +6 -0
  82. package/pan-wizard-core/templates/summary-standard.md +14 -3
  83. package/pan-wizard-core/workflows/discuss-phase.md +108 -1
  84. package/pan-wizard-core/workflows/exec-phase.md +37 -1
  85. package/pan-wizard-core/workflows/execute-plan.md +14 -0
  86. package/pan-wizard-core/workflows/health.md +23 -0
  87. package/pan-wizard-core/workflows/new-project.md +65 -81
  88. package/pan-wizard-core/workflows/plan-phase.md +58 -0
  89. package/pan-wizard-core/workflows/transition.md +102 -7
  90. package/pan-wizard-core/workflows/verify-phase.md +14 -0
  91. package/scripts/build-hooks.js +7 -1
  92. package/scripts/generate-skills-docs.py +10 -8
  93. package/scripts/release-check.js +184 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Learn-Index — Build a topic→agent-relevance index for the learnings store.
3
+ *
4
+ * Solves the P-RES-002 anti-pattern PAN currently ships: workflow files say
5
+ * "Also see learnings/universal/" and the agent skim-loads ~25 files into
6
+ * context every plan/exec turn. Distractor density (per Chroma 2025) degrades
7
+ * agent performance even at modest token counts.
8
+ *
9
+ * The index lets agents load only the topics relevant to their role at the
10
+ * current phase, with token budgets visible.
11
+ *
12
+ * Generated by: `pan-tools learn build-index`
13
+ * Read by: `pan-tools learn topics-for --agent <name>`
14
+ * Stored at: pan-wizard-core/learnings/index.json
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { collectAllPatterns } = require('./learn-lint.cjs');
20
+
21
+ const CHARS_PER_TOKEN = 4;
22
+
23
+ const INDEX_PATH_REL = path.join('pan-wizard-core', 'learnings', 'index.json');
24
+
25
+ /**
26
+ * Static topic→agent-role relevance table. Curated, not heuristic — these
27
+ * judgments come from "what does each agent role NEED to know to do its job
28
+ * well?" (per the agent definitions in agents/pan-{planner,executor,verifier,
29
+ * reviewer,plan-checker}.md).
30
+ *
31
+ * Values: 'high' = always load; 'medium' = load when relevant phase domain
32
+ * matches; 'low' = load only when explicitly cited.
33
+ *
34
+ * If a topic is missing from the table, default is medium for executor and
35
+ * low for the others (assumes new topics are mostly executor-side patterns
36
+ * unless tagged otherwise).
37
+ */
38
+ const RELEVANCE = {
39
+ // Planning-time concerns
40
+ 'design-process': { planner: 'high', executor: 'low', verifier: 'low', reviewer: 'medium' },
41
+ 'schema-design': { planner: 'high', executor: 'medium', verifier: 'medium', reviewer: 'medium' },
42
+ 'composition': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'medium' },
43
+ 'dag-scheduler': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'medium' },
44
+ 'data-driven-design': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'medium' },
45
+ 'vendor-pattern': { planner: 'medium', executor: 'low', verifier: 'low', reviewer: 'medium' },
46
+ 'phase-locking': { planner: 'high', executor: 'low', verifier: 'low', reviewer: 'low' },
47
+
48
+ // Execution-time concerns
49
+ 'atomic-state': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'medium' },
50
+ 'binary-io': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
51
+ 'concurrency': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'medium' },
52
+ 'streaming-io': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
53
+ 'io-patterns': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
54
+ 'parser-design': { planner: 'medium', executor: 'high', verifier: 'medium', reviewer: 'medium' },
55
+ 'error-handling': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
56
+ 'error-paths': { planner: 'low', executor: 'high', verifier: 'high', reviewer: 'high' },
57
+ 'invariants': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
58
+ 'numeric-edge-cases': { planner: 'low', executor: 'high', verifier: 'high', reviewer: 'medium' },
59
+ 'unicode': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
60
+ 'glob-semantics': { planner: 'low', executor: 'medium', verifier: 'medium', reviewer: 'low' },
61
+ 'comment-syntax': { planner: 'low', executor: 'medium', verifier: 'low', reviewer: 'medium' },
62
+ 'output-conventions': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
63
+ 'pipe-friendly-cli': { planner: 'medium', executor: 'high', verifier: 'low', reviewer: 'low' },
64
+ 'empirical-spike': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'medium' },
65
+ 'idempotency': { planner: 'high', executor: 'high', verifier: 'high', reviewer: 'high' },
66
+ 'secret-handling': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
67
+
68
+ // Verification-time concerns
69
+ 'test-patterns': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
70
+ 'test-strategy': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'high' },
71
+
72
+ // PAN-internal (loaded only by PAN's own dev sessions)
73
+ 'experiment-runner': { planner: 'low', executor: 'low', verifier: 'low', reviewer: 'low' },
74
+ 'external-research': { planner: 'medium', executor: 'low', verifier: 'low', reviewer: 'medium' },
75
+ 'pan-dev-bugs': { planner: 'low', executor: 'low', verifier: 'low', reviewer: 'low' },
76
+ 'loop-design': { planner: 'medium', executor: 'low', verifier: 'low', reviewer: 'low' },
77
+ };
78
+
79
+ /**
80
+ * Build the index from the on-disk learnings store.
81
+ */
82
+ function buildIndex(sourceRoot) {
83
+ const all = collectAllPatterns(sourceRoot);
84
+ const byTopic = new Map();
85
+ for (const p of all) {
86
+ const key = `${p.scope}/${p.topic}`;
87
+ if (!byTopic.has(key)) {
88
+ byTopic.set(key, {
89
+ name: p.topic,
90
+ scope: p.scope,
91
+ file: path.relative(sourceRoot, p.file).replace(/\\/g, '/'),
92
+ patterns: [],
93
+ size_bytes: 0,
94
+ size_tokens_est: 0,
95
+ });
96
+ }
97
+ const entry = byTopic.get(key);
98
+ entry.patterns.push(p.id);
99
+ }
100
+
101
+ for (const [, entry] of byTopic) {
102
+ const abs = path.join(sourceRoot, entry.file);
103
+ try {
104
+ const stat = fs.statSync(abs);
105
+ entry.size_bytes = stat.size;
106
+ entry.size_tokens_est = Math.ceil(stat.size / CHARS_PER_TOKEN);
107
+ } catch {
108
+ entry.size_bytes = 0;
109
+ entry.size_tokens_est = 0;
110
+ }
111
+ const rel = RELEVANCE[entry.name] || { planner: 'low', executor: 'medium', verifier: 'low', reviewer: 'low' };
112
+ entry.agent_relevance = rel;
113
+ }
114
+
115
+ const topics = [...byTopic.values()].sort((a, b) => {
116
+ if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
117
+ return a.name.localeCompare(b.name);
118
+ });
119
+
120
+ return {
121
+ schema_version: 1,
122
+ generated_at: new Date().toISOString(),
123
+ topics,
124
+ totals: {
125
+ topics: topics.length,
126
+ patterns: topics.reduce((s, t) => s + t.patterns.length, 0),
127
+ size_bytes: topics.reduce((s, t) => s + t.size_bytes, 0),
128
+ size_tokens_est: topics.reduce((s, t) => s + t.size_tokens_est, 0),
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Write the index to disk.
135
+ */
136
+ function writeIndex(sourceRoot, index) {
137
+ const filePath = path.join(sourceRoot, INDEX_PATH_REL);
138
+ fs.writeFileSync(filePath, JSON.stringify(index, null, 2) + '\n');
139
+ return filePath;
140
+ }
141
+
142
+ /**
143
+ * Read the index from disk; build it if missing.
144
+ */
145
+ function readIndex(sourceRoot) {
146
+ const filePath = path.join(sourceRoot, INDEX_PATH_REL);
147
+ try {
148
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
149
+ } catch {
150
+ return buildIndex(sourceRoot);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Query: what topics should agent X load at this point?
156
+ *
157
+ * @param {object} index - the index object
158
+ * @param {object} opts
159
+ * @param {string} opts.agent - 'planner' | 'executor' | 'verifier' | 'reviewer'
160
+ * @param {string} [opts.minRelevance] - 'high' | 'medium' | 'low' (default 'medium')
161
+ * @param {number} [opts.tokenBudget] - max tokens to fit (default 5000)
162
+ * @returns {object} { topics: [{name, scope, file, tokens, relevance}], total_tokens, dropped: [...] }
163
+ */
164
+ function topicsForAgent(index, opts) {
165
+ const agent = opts.agent;
166
+ const minLevel = opts.minRelevance || 'medium';
167
+ const budget = opts.tokenBudget || 5000;
168
+ const ranks = { high: 3, medium: 2, low: 1 };
169
+ const minRank = ranks[minLevel] || 2;
170
+
171
+ const ranked = [];
172
+ for (const t of index.topics) {
173
+ const rel = (t.agent_relevance || {})[agent] || 'low';
174
+ const rank = ranks[rel] || 1;
175
+ if (rank < minRank) continue;
176
+ ranked.push({ ...t, _relevance: rel, _rank: rank });
177
+ }
178
+ ranked.sort((a, b) => b._rank - a._rank || a.size_tokens_est - b.size_tokens_est);
179
+
180
+ const selected = [];
181
+ const dropped = [];
182
+ let total = 0;
183
+ for (const t of ranked) {
184
+ if (total + t.size_tokens_est > budget) {
185
+ dropped.push({ name: t.name, scope: t.scope, tokens: t.size_tokens_est, relevance: t._relevance });
186
+ continue;
187
+ }
188
+ selected.push({
189
+ name: t.name,
190
+ scope: t.scope,
191
+ file: t.file,
192
+ tokens: t.size_tokens_est,
193
+ relevance: t._relevance,
194
+ patterns: t.patterns,
195
+ });
196
+ total += t.size_tokens_est;
197
+ }
198
+
199
+ return {
200
+ agent,
201
+ min_relevance: minLevel,
202
+ token_budget: budget,
203
+ selected,
204
+ dropped,
205
+ total_tokens: total,
206
+ };
207
+ }
208
+
209
+ function cmdBuildIndex(sourceRoot) {
210
+ const index = buildIndex(sourceRoot);
211
+ const filePath = writeIndex(sourceRoot, index);
212
+ return {
213
+ written_to: filePath,
214
+ topics: index.totals.topics,
215
+ patterns: index.totals.patterns,
216
+ total_tokens_est: index.totals.size_tokens_est,
217
+ schema_version: index.schema_version,
218
+ };
219
+ }
220
+
221
+ function cmdTopicsFor(sourceRoot, opts) {
222
+ const index = readIndex(sourceRoot);
223
+ return topicsForAgent(index, opts);
224
+ }
225
+
226
+ module.exports = {
227
+ buildIndex,
228
+ writeIndex,
229
+ readIndex,
230
+ topicsForAgent,
231
+ cmdBuildIndex,
232
+ cmdTopicsFor,
233
+ INDEX_PATH_REL,
234
+ RELEVANCE,
235
+ };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Learn-Lint — Validate the learnings system itself.
3
+ *
4
+ * Catches integrity violations that erode trust in the learnings PAN ships
5
+ * to executor / planner / verifier agents:
6
+ *
7
+ * - L-001: Duplicate pattern IDs across files (silent contract collision)
8
+ * - L-002: Dangling pattern reference (body cites P-XXXX that doesn't exist)
9
+ * - L-003: Empty source_experiments while evidence prose names a known experiment
10
+ * - L-004: Universal-scope rule prose mentions PAN-internal terms
11
+ * (candidate for internal/ scope rather than universal/)
12
+ * - L-005: Revision marker (rN) appended in body but no supersession field
13
+ *
14
+ * These are not patterns themselves — they're integrity checks for the
15
+ * pattern store. Wired to `pan-tools learn lint`.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const VALID_SCOPES = ['universal', 'internal'];
22
+
23
+ function getLearningsDir(sourceRoot, scope) {
24
+ return path.join(sourceRoot, 'pan-wizard-core', 'learnings', scope);
25
+ }
26
+
27
+ function readTopicFile(filePath) {
28
+ const content = fs.readFileSync(filePath, 'utf-8');
29
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
30
+ if (!fmMatch) return { frontmatter: { patterns: [] }, body: content };
31
+ const fmText = fmMatch[1];
32
+ const body = fmMatch[2];
33
+ const fm = parseFrontmatter(fmText);
34
+ return { frontmatter: fm, body };
35
+ }
36
+
37
+ function parseFrontmatter(text) {
38
+ const out = { topic: '', last_updated: '', patterns: [] };
39
+ const lines = text.split('\n');
40
+ let inPatterns = false;
41
+ let current = null;
42
+ for (const line of lines) {
43
+ if (line === 'patterns:') { inPatterns = true; continue; }
44
+ if (!inPatterns) {
45
+ const m = line.match(/^([a-z_]+):\s*(.*)$/);
46
+ if (m) out[m[1]] = m[2].trim();
47
+ continue;
48
+ }
49
+ if (line.startsWith(' - id:')) {
50
+ if (current) out.patterns.push(current);
51
+ current = { id: line.replace(/^\s*- id:\s*/, '').trim() };
52
+ } else if (current) {
53
+ const m = line.match(/^\s+([a-z_]+):\s*(.*)$/);
54
+ if (m) {
55
+ const key = m[1];
56
+ let val = m[2].trim();
57
+ if (val.startsWith('[') && val.endsWith(']')) {
58
+ val = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
59
+ }
60
+ current[key] = val;
61
+ }
62
+ }
63
+ }
64
+ if (current) out.patterns.push(current);
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Walk both scopes and collect every pattern with full body context.
70
+ */
71
+ function collectAllPatterns(sourceRoot) {
72
+ const all = [];
73
+ for (const scope of VALID_SCOPES) {
74
+ const dir = getLearningsDir(sourceRoot, scope);
75
+ let files;
76
+ try {
77
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'README.md');
78
+ } catch {
79
+ continue;
80
+ }
81
+ for (const file of files) {
82
+ const filePath = path.join(dir, file);
83
+ const { frontmatter, body } = readTopicFile(filePath);
84
+ const topic = file.replace(/\.md$/, '');
85
+ for (const p of frontmatter.patterns) {
86
+ const patternBody = extractPatternBody(body, p.id);
87
+ all.push({
88
+ id: p.id,
89
+ scope,
90
+ topic,
91
+ file: filePath,
92
+ summary: p.summary || '',
93
+ source_experiments: Array.isArray(p.source_experiments) ? p.source_experiments : [],
94
+ superseded_by: p.superseded_by || null,
95
+ superseded_id: p.superseded_id || null,
96
+ body: patternBody,
97
+ });
98
+ }
99
+ }
100
+ }
101
+ return all;
102
+ }
103
+
104
+ /**
105
+ * Find a pattern's narrative body (the section between `## P-XXX —` and the
106
+ * next `## ` or end of file).
107
+ */
108
+ function extractPatternBody(fileBody, patternId) {
109
+ const escaped = patternId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ const re = new RegExp(`(^|\\n)## ${escaped}[\\s\\S]*?(?=\\n## [A-Z]|$)`, 'i');
111
+ const m = fileBody.match(re);
112
+ return m ? m[0] : '';
113
+ }
114
+
115
+ /**
116
+ * Extract a single named bold-field section from a pattern body.
117
+ * Returns text between `**Field:**` and the next `**Other:**` block.
118
+ */
119
+ function extractFieldSection(body, field) {
120
+ const re = new RegExp(`\\*\\*${field}:\\*\\*([\\s\\S]*?)(?=\\n\\*\\*[A-Z]|$)`, 'i');
121
+ const m = body.match(re);
122
+ return m ? m[1].trim() : '';
123
+ }
124
+
125
+ const KNOWN_EXPERIMENT_TOKENS = [
126
+ 'notepadrs',
127
+ 'whoolog', 'whoocache', 'whooflow', 'whooschema', 'whoodb',
128
+ 'whoorun', 'whoograph', 'whoocsv', 'whoodag', 'whoodiff',
129
+ 'whooemoji', 'whoohash', 'whoolen', 'whoosort', 'whooo',
130
+ 'panloop', 'panloop2', 'panmd2', 'panmd3', 'panrhi',
131
+ ];
132
+
133
+ const PAN_INTERNAL_TERMS = [
134
+ /\bPAN(?:'s)?\b/,
135
+ /\bv3\.\d+(\.\d+)?/,
136
+ /\bpan-wizard-core\b/,
137
+ /\bpan-tools\b/,
138
+ /\b\.planning\//,
139
+ /\bpan-(?:planner|executor|verifier|reviewer)\b/,
140
+ ];
141
+
142
+ const PATTERN_REF_RE = /\bP-(?:[A-Z]+-)?\d+(?:-r\d+)?\b/g;
143
+
144
+ /**
145
+ * Run all integrity checks on the collected patterns.
146
+ *
147
+ * Returns:
148
+ * {
149
+ * violations: [{ code, severity, pattern_id, file, message, ... }],
150
+ * pattern_count, file_count, scopes
151
+ * }
152
+ */
153
+ function lintPatterns(patterns) {
154
+ const violations = [];
155
+
156
+ const idIndex = new Map(); // id -> [{scope, topic, file}, ...]
157
+ for (const p of patterns) {
158
+ if (!idIndex.has(p.id)) idIndex.set(p.id, []);
159
+ idIndex.get(p.id).push({ scope: p.scope, topic: p.topic, file: p.file });
160
+ }
161
+
162
+ for (const [id, locations] of idIndex.entries()) {
163
+ if (locations.length > 1) {
164
+ violations.push({
165
+ code: 'L-001',
166
+ severity: 'error',
167
+ pattern_id: id,
168
+ message: `Pattern ID "${id}" defined in ${locations.length} places`,
169
+ locations,
170
+ });
171
+ }
172
+ }
173
+
174
+ const knownIds = new Set(idIndex.keys());
175
+
176
+ for (const p of patterns) {
177
+ const refs = (p.body.match(PATTERN_REF_RE) || []).filter(r => r !== p.id);
178
+ const dangling = [...new Set(refs)].filter(r => !knownIds.has(r));
179
+ for (const ref of dangling) {
180
+ violations.push({
181
+ code: 'L-002',
182
+ severity: 'error',
183
+ pattern_id: p.id,
184
+ file: p.file,
185
+ message: `Pattern "${p.id}" references "${ref}" which is not defined in any topic file`,
186
+ dangling_ref: ref,
187
+ });
188
+ }
189
+ }
190
+
191
+ for (const p of patterns) {
192
+ if (p.source_experiments.length > 0) continue;
193
+ const lower = p.body.toLowerCase();
194
+ const cited = KNOWN_EXPERIMENT_TOKENS.filter(t => lower.includes(t.toLowerCase()));
195
+ if (cited.length > 0) {
196
+ violations.push({
197
+ code: 'L-003',
198
+ severity: 'warning',
199
+ pattern_id: p.id,
200
+ file: p.file,
201
+ message: `Pattern "${p.id}" cites experiment(s) ${JSON.stringify(cited)} in evidence prose but source_experiments frontmatter is empty`,
202
+ cited_experiments: cited,
203
+ });
204
+ }
205
+ }
206
+
207
+ for (const p of patterns) {
208
+ if (p.scope !== 'universal') continue;
209
+ const ruleSection = extractFieldSection(p.body, 'Rule');
210
+ const headingMatch = p.body.match(/^\s*##\s+([^\n]+)/m);
211
+ const heading = headingMatch ? headingMatch[1] : '';
212
+ const scanText = `${heading}\n${ruleSection}`;
213
+ const matches = [];
214
+ for (const re of PAN_INTERNAL_TERMS) {
215
+ const m = scanText.match(re);
216
+ if (m) matches.push(m[0]);
217
+ }
218
+ if (matches.length > 0) {
219
+ violations.push({
220
+ code: 'L-004',
221
+ severity: 'warning',
222
+ pattern_id: p.id,
223
+ file: p.file,
224
+ message: `Universal-scope pattern "${p.id}" mentions PAN-internal terms ${JSON.stringify([...new Set(matches)])} in heading or Rule section — candidate for internal/ scope`,
225
+ terms: [...new Set(matches)],
226
+ });
227
+ }
228
+ }
229
+
230
+ for (const p of patterns) {
231
+ const revMatch = p.id.match(/^(P-[A-Z0-9]+(?:-[A-Z0-9]+)?)-r(\d+)$/);
232
+ if (!revMatch) continue;
233
+ const baseId = revMatch[1];
234
+ const revNum = revMatch[2];
235
+ const basePattern = patterns.find(x => x.file === p.file && x.id === baseId);
236
+ if (!basePattern) continue;
237
+ const hasSupersession = basePattern.superseded_by || basePattern.superseded_id || /supersed/i.test(basePattern.body);
238
+ if (!hasSupersession) {
239
+ violations.push({
240
+ code: 'L-005',
241
+ severity: 'warning',
242
+ pattern_id: p.id,
243
+ file: p.file,
244
+ message: `Revision pattern "${p.id}" exists but base "${baseId}" has no superseded_by field in frontmatter`,
245
+ base_id: baseId,
246
+ revision: revNum,
247
+ });
248
+ }
249
+ }
250
+
251
+ return {
252
+ violations,
253
+ pattern_count: patterns.length,
254
+ file_count: new Set(patterns.map(p => p.file)).size,
255
+ scopes: VALID_SCOPES,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * CLI entry: lint the learnings store.
261
+ *
262
+ * @param {string} sourceRoot
263
+ * @param {object} opts - { scope?: 'universal'|'internal', strict?: boolean }
264
+ */
265
+ function cmdLearnLint(sourceRoot, opts = {}) {
266
+ const all = collectAllPatterns(sourceRoot);
267
+ const filtered = opts.scope
268
+ ? all.filter(p => p.scope === opts.scope)
269
+ : all;
270
+ const result = lintPatterns(filtered);
271
+
272
+ const errors = result.violations.filter(v => v.severity === 'error').length;
273
+ const warnings = result.violations.filter(v => v.severity === 'warning').length;
274
+
275
+ result.summary = {
276
+ total_violations: result.violations.length,
277
+ errors,
278
+ warnings,
279
+ status: errors > 0 || (opts.strict && warnings > 0) ? 'fail' : 'pass',
280
+ };
281
+
282
+ return result;
283
+ }
284
+
285
+ module.exports = {
286
+ cmdLearnLint,
287
+ collectAllPatterns,
288
+ lintPatterns,
289
+ extractPatternBody,
290
+ extractFieldSection,
291
+ KNOWN_EXPERIMENT_TOKENS,
292
+ };