@sienklogic/plan-build-run 2.38.1 → 2.40.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +1 -1
  3. package/plugins/copilot-pbr/agents/executor.agent.md +13 -0
  4. package/plugins/copilot-pbr/plugin.json +1 -1
  5. package/plugins/copilot-pbr/references/config-reference.md +22 -0
  6. package/plugins/copilot-pbr/references/git-integration.md +30 -0
  7. package/plugins/copilot-pbr/references/plan-format.md +4 -0
  8. package/plugins/copilot-pbr/skills/begin/SKILL.md +22 -0
  9. package/plugins/copilot-pbr/skills/build/SKILL.md +45 -0
  10. package/plugins/copilot-pbr/skills/explore/SKILL.md +17 -0
  11. package/plugins/copilot-pbr/skills/milestone/SKILL.md +54 -0
  12. package/plugins/copilot-pbr/templates/pr-body.md.tmpl +22 -0
  13. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  14. package/plugins/cursor-pbr/agents/executor.md +13 -0
  15. package/plugins/cursor-pbr/references/config-reference.md +22 -0
  16. package/plugins/cursor-pbr/references/git-integration.md +30 -0
  17. package/plugins/cursor-pbr/references/plan-format.md +4 -0
  18. package/plugins/cursor-pbr/skills/begin/SKILL.md +22 -0
  19. package/plugins/cursor-pbr/skills/build/SKILL.md +45 -0
  20. package/plugins/cursor-pbr/skills/explore/SKILL.md +17 -0
  21. package/plugins/cursor-pbr/skills/milestone/SKILL.md +54 -0
  22. package/plugins/cursor-pbr/templates/pr-body.md.tmpl +22 -0
  23. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  24. package/plugins/pbr/agents/executor.md +13 -0
  25. package/plugins/pbr/references/config-reference.md +22 -0
  26. package/plugins/pbr/references/git-integration.md +30 -0
  27. package/plugins/pbr/references/plan-format.md +4 -0
  28. package/plugins/pbr/scripts/lib/learnings.js +312 -0
  29. package/plugins/pbr/scripts/milestone-learnings.js +290 -0
  30. package/plugins/pbr/scripts/pbr-tools.js +43 -1
  31. package/plugins/pbr/scripts/progress-tracker.js +24 -1
  32. package/plugins/pbr/skills/begin/SKILL.md +23 -0
  33. package/plugins/pbr/skills/build/SKILL.md +45 -0
  34. package/plugins/pbr/skills/explore/SKILL.md +16 -0
  35. package/plugins/pbr/skills/milestone/SKILL.md +54 -0
  36. package/plugins/pbr/skills/plan/SKILL.md +23 -0
  37. package/plugins/pbr/templates/pr-body.md.tmpl +22 -0
@@ -413,6 +413,28 @@ Archive a completed milestone and prepare for the next one.
413
413
  - Key deliverables: {summary from Step 4}
414
414
  ```
415
415
 
416
+ 7d. **Aggregate learnings from milestone phases:**
417
+
418
+ **CRITICAL: Run learnings aggregation NOW. Do NOT skip this step.**
419
+
420
+ ```bash
421
+ node ${PLUGIN_ROOT}/scripts/milestone-learnings.js .planning/milestones/{version} --project {project-name-from-STATE.md}
422
+ ```
423
+
424
+ - If the script outputs an error, log it but do NOT abort milestone completion — learnings aggregation is advisory.
425
+ - Display the aggregation summary line to the user (e.g., "Learnings aggregated: 12 new, 3 updated, 0 errors").
426
+ - After aggregation, check for triggered deferral thresholds:
427
+
428
+ ```bash
429
+ node ${PLUGIN_ROOT}/scripts/pbr-tools.js learnings check-thresholds
430
+ ```
431
+
432
+ If any thresholds are triggered, display each as a notification:
433
+
434
+ ```
435
+ Note: Learnings threshold met — {key}: {trigger}. Consider implementing the deferred feature.
436
+ ```
437
+
416
438
  8. **Git tag:**
417
439
  ```bash
418
440
  git tag -a {version} -m "Milestone: {name}"
@@ -424,6 +446,38 @@ Archive a completed milestone and prepare for the next one.
424
446
  git commit -m "docs(planning): complete milestone {version}"
425
447
  ```
426
448
 
449
+ 9b. **Push milestone to remote:**
450
+
451
+ Use AskUserQuestion to ask the user how they want to publish the milestone:
452
+
453
+ ```
454
+ question: "How should this milestone be published to GitHub?"
455
+ header: "Publish"
456
+ options:
457
+ - label: "Push tag + commits" description: "Push the v{version} tag and any unpushed commits to origin"
458
+ - label: "Skip for now" description: "Keep everything local — push later manually"
459
+ ```
460
+
461
+ - If "Push tag + commits": run `git push origin main --follow-tags` to push both commits and the annotated tag in one command. Display success or error.
462
+ - If "Skip for now": display reminder: "Tag v{version} is local only. Push when ready: `git push origin main --follow-tags`"
463
+ - If "Other": follow user instructions (e.g., create a PR, push to a different branch, etc.)
464
+
465
+ ### Post-Completion Smoke Test
466
+
467
+ If `config.deployment.smoke_test_command` is set and non-empty:
468
+
469
+ 1. Run the command via Bash
470
+ 2. If exit code 0: display "Smoke test passed" with command output
471
+ 3. If exit code non-zero: display advisory warning:
472
+
473
+ ```
474
+ ⚠ Smoke test failed (exit code {N})
475
+ Command: {smoke_test_command}
476
+ Output: {first 20 lines of output}
477
+ ```
478
+
479
+ This is advisory only — the milestone is already archived. Surface it as a potential issue for the user to investigate.
480
+
427
481
  10. **Confirm** with branded output:
428
482
  ```
429
483
  ╔══════════════════════════════════════════════════════════════╗
@@ -0,0 +1,22 @@
1
+ ## Phase <%= phase_number %>: <%= phase_name %>
2
+
3
+ **Goal**: <%= phase_goal %>
4
+
5
+ ### Key Files Changed
6
+ <% key_files.forEach(f => { %>
7
+ - `<%= f %>`
8
+ <% }) %>
9
+
10
+ ### Verification
11
+ - Status: <%= verification_status %>
12
+ - Must-haves: <%= must_haves_passed %>/<%= must_haves_total %> passed
13
+
14
+ <% if (closes_issues && closes_issues.length > 0) { %>
15
+ ### Issues
16
+ <% closes_issues.forEach(n => { %>
17
+ Closes #<%= n %>
18
+ <% }) %>
19
+ <% } %>
20
+
21
+ ---
22
+ *Generated by [Plan-Build-Run](https://github.com/SienkLogic/plan-build-run)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.38.1",
3
+ "version": "2.40.0",
4
4
  "description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
5
5
  "author": {
6
6
  "name": "SienkLogic",
@@ -120,6 +120,19 @@ One task = one commit. Exception: TDD tasks get 3 commits (RED, GREEN, REFACTOR)
120
120
 
121
121
  Stage only files listed in the task's `<files>`. If git commit fails with lock error, retry up to 3 times with 2s delay.
122
122
 
123
+ ### Issue Auto-Close
124
+
125
+ When the plan frontmatter contains a non-empty `closes_issues` array, append issue-closing syntax to the **final** commit body for the plan:
126
+
127
+ ```
128
+ git commit -m "feat(01-02): implement user auth
129
+
130
+ Closes #42
131
+ Closes #57"
132
+ ```
133
+
134
+ Only append to the LAST commit of the plan — intermediate commits (RED/GREEN in TDD, partial progress) should NOT include closing syntax.
135
+
123
136
  ---
124
137
 
125
138
  ## Deviation Rules
@@ -155,11 +155,23 @@ Controls git integration and branching strategy.
155
155
  | `phase_branch_template` | string | `plan-build-run/phase-{phase}-{slug}` | Phase branch name pattern |
156
156
  | `milestone_branch_template` | string | `plan-build-run/{milestone}-{slug}` | Milestone branch name pattern |
157
157
  | `mode` | string | `enabled` | Git mode: `enabled` or `disabled` |
158
+ | `auto_pr` | boolean | `false` | Create a GitHub PR after successful phase verification when branching is enabled |
158
159
 
159
160
  When `git.mode` is `disabled`, no git commands run at all -- no commits, branching, or hook validation. Useful for prototyping or non-git projects. See `references/git-integration.md` for full branching strategy details.
160
161
 
161
162
  ---
162
163
 
164
+ ## ci
165
+
166
+ Controls CI integration for build gates.
167
+
168
+ | Property | Type | Default | Description |
169
+ |----------|------|---------|-------------|
170
+ | `ci.gate_enabled` | boolean | `false` | Block wave advancement until CI passes |
171
+ | `ci.wait_timeout_seconds` | number | `120` | Max seconds to wait for CI completion |
172
+
173
+ ---
174
+
163
175
  ## gates
164
176
 
165
177
  Confirmation gates that pause execution to ask the user before proceeding. Setting a gate to `false` makes that step automatic.
@@ -219,6 +231,16 @@ This value is overridden by the active depth profile if a `depth_profiles` entry
219
231
 
220
232
  ---
221
233
 
234
+ ## deployment
235
+
236
+ Controls post-milestone deployment verification.
237
+
238
+ | Property | Type | Default | Description |
239
+ |----------|------|---------|-------------|
240
+ | `deployment.smoke_test_command` | string | `""` | Bash command to run after milestone completion (e.g., `"curl -sf https://myapp.com/health"`) |
241
+
242
+ ---
243
+
222
244
  ## depth_profiles
223
245
 
224
246
  Override the built-in depth profile defaults. Each key (`quick`, `standard`, `comprehensive`) maps to an object of settings that take effect when that depth is active.
@@ -174,6 +174,36 @@ Branch name templates are configured in `config.json`:
174
174
  - `git.phase_branch_template`: Default `plan-build-run/phase-{phase}-{slug}`
175
175
  - `git.milestone_branch_template`: Default `plan-build-run/{milestone}-{slug}`
176
176
 
177
+ ### PR Creation
178
+
179
+ When `git.auto_pr: true` and `git.branching` is `phase` or `milestone`, the build skill creates a GitHub PR after verification passes:
180
+
181
+ 1. Push the phase branch to the remote
182
+ 2. Create a PR via `gh pr create` with structured title and body
183
+ 3. PR title follows the commit convention: `feat(phase-{N}): {phase slug}`
184
+ 4. PR body includes phase goal, key files changed, and must-have verification results
185
+
186
+ When `git.auto_pr: false` (default), the build skill offers the user a choice after verification:
187
+ - Create PR now
188
+ - Skip PR creation
189
+ - Push branch only (create PR later)
190
+
191
+ PR creation requires `gh` CLI authenticated with repo write access.
192
+
193
+ ### CI Integration
194
+
195
+ When `ci.gate_enabled: true`, the build skill checks GitHub Actions status after each wave completes:
196
+
197
+ 1. Run: `gh run list --branch $(git branch --show-current) --limit 1 --json status,conclusion,url`
198
+ 2. If `status == completed` and `conclusion == success`: proceed to next wave
199
+ 3. If `status == in_progress`: wait up to `ci.wait_timeout_seconds`, re-check
200
+ 4. If `conclusion != success` or timeout: surface warning with run URL and offer:
201
+ - **Wait**: continue polling
202
+ - **Continue anyway**: proceed despite CI failure (logged as deviation)
203
+ - **Abort**: stop the build
204
+
205
+ CI gate requires `gh` CLI and GitHub Actions configured on the repository. The gate only activates when there are commits pushed to a remote branch (branching must be enabled).
206
+
177
207
  ### Git Mode
178
208
 
179
209
  The `git.mode` field controls whether git integration is active:
@@ -46,6 +46,9 @@ consumes:
46
46
  requirement_ids:
47
47
  - "P02-G1"
48
48
  - "P02-G2"
49
+ closes_issues:
50
+ - 42
51
+ - 57
49
52
  ---
50
53
  ```
51
54
 
@@ -71,6 +74,7 @@ requirement_ids:
71
74
  | `requirement_ids` | NO | array | Requirement IDs from REQUIREMENTS.md or ROADMAP.md goal IDs that this plan addresses. Enables bidirectional traceability between plans and requirements/goals. |
72
75
  | `dependency_fingerprints` | NO | object | Hashes of dependency phase SUMMARY.md files at plan-creation time. Used to detect stale plans. |
73
76
  | `data_contracts` | NO | array | Cross-boundary parameter mappings for calls where arguments originate from external boundaries. Format: `"param: source (context) [fallback]"` |
77
+ | `closes_issues` | NO | number[] | GitHub issue numbers to close when this plan's final commit lands. Default: `[]` |
74
78
 
75
79
  ### Data Contracts
76
80
 
@@ -0,0 +1,312 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * learnings.js — Global cross-project learnings store for Plan-Build-Run.
5
+ *
6
+ * Storage: JSONL at ~/.claude/learnings.jsonl
7
+ * Schema v1: id, source_project, type, tags, confidence, occurrences, summary, detail, custom_tags
8
+ *
9
+ * Usage (library):
10
+ * const { learningsIngest, learningsQuery } = require('./lib/learnings');
11
+ *
12
+ * Usage (CLI via pbr-tools.js):
13
+ * node pbr-tools.js learnings ingest <json-file>
14
+ * node pbr-tools.js learnings query [--tags X] [--min-confidence Y] [--stack S] [--type T]
15
+ * node pbr-tools.js learnings check-thresholds
16
+ */
17
+
18
+ const os = require('os');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+
23
+ // --- Constants ---
24
+
25
+ const GLOBAL_LEARNINGS_PATH = path.join(os.homedir(), '.claude', 'learnings.jsonl');
26
+
27
+ // Schema v1 type taxonomy
28
+ const LEARNING_TYPES = [
29
+ 'tech-pattern', // positive: "this tech/pattern worked well"
30
+ 'anti-pattern', // negative: "avoid this approach"
31
+ 'estimation-metric', // timing/sizing data (e.g., "OAuth takes ~3 phases")
32
+ 'planning-failure', // what went wrong in planning
33
+ 'deferred-item', // common deferrals with trigger conditions
34
+ 'stack-insight', // tech stack compatibility facts
35
+ 'process-win', // positive process patterns
36
+ 'process-failure' // negative process patterns
37
+ ];
38
+
39
+ // Confidence tiers based on occurrence count
40
+ const CONFIDENCE_TIERS = { low: 1, medium: 2, high: 3 };
41
+
42
+ // Deferral thresholds for the 4 deferred items from phase 45
43
+ const DEFERRAL_THRESHOLDS = [
44
+ { key: 'organic-taxonomy', trigger: 'count > 50', check: (count) => count > 50 },
45
+ { key: 'statistical-confidence', trigger: 'any_tag >= 20', check: (_c, tagMax) => tagMax >= 20 },
46
+ { key: 'audit-integration', trigger: 'audits > 10', check: (_c, _t, audits) => audits > 10 },
47
+ { key: 'executor-injection', trigger: 'queries > 30', check: (_c, _t, _a, queries) => queries > 30 }
48
+ ];
49
+
50
+ // --- Core functions ---
51
+
52
+ /**
53
+ * Compute confidence tier from occurrence count.
54
+ * @param {number} occurrences
55
+ * @returns {'low'|'medium'|'high'}
56
+ */
57
+ function computeConfidence(occurrences) {
58
+ if (occurrences >= 3) return 'high';
59
+ if (occurrences === 2) return 'medium';
60
+ return 'low';
61
+ }
62
+
63
+ /**
64
+ * Validate a learning entry against schema v1.
65
+ * @param {object} entry
66
+ * @returns {{ valid: boolean, errors: string[] }}
67
+ */
68
+ function validateEntry(entry) {
69
+ const errors = [];
70
+
71
+ if (!entry) {
72
+ return { valid: false, errors: ['entry is null or undefined'] };
73
+ }
74
+
75
+ // Required fields
76
+ const required = ['id', 'source_project', 'type', 'tags', 'confidence', 'occurrences', 'summary'];
77
+ for (const field of required) {
78
+ if (entry[field] === undefined || entry[field] === null) {
79
+ errors.push(`missing required field: ${field}`);
80
+ }
81
+ }
82
+
83
+ // Type must be in taxonomy
84
+ if (entry.type !== undefined && !LEARNING_TYPES.includes(entry.type)) {
85
+ errors.push(`invalid type: "${entry.type}". Must be one of: ${LEARNING_TYPES.join(', ')}`);
86
+ }
87
+
88
+ // Confidence must be valid tier
89
+ if (entry.confidence !== undefined && !['low', 'medium', 'high'].includes(entry.confidence)) {
90
+ errors.push(`invalid confidence: "${entry.confidence}". Must be one of: low, medium, high`);
91
+ }
92
+
93
+ // Occurrences must be positive integer
94
+ if (entry.occurrences !== undefined) {
95
+ if (!Number.isInteger(entry.occurrences) || entry.occurrences < 1) {
96
+ errors.push(`occurrences must be a positive integer, got: ${entry.occurrences}`);
97
+ }
98
+ }
99
+
100
+ // Tags must be a non-empty array of strings
101
+ if (entry.tags !== undefined) {
102
+ if (!Array.isArray(entry.tags)) {
103
+ errors.push('tags must be an array');
104
+ } else if (entry.tags.length === 0) {
105
+ errors.push('tags must be a non-empty array');
106
+ } else {
107
+ const nonStrings = entry.tags.filter(t => typeof t !== 'string');
108
+ if (nonStrings.length > 0) {
109
+ errors.push(`all tags must be strings; got ${nonStrings.length} non-string value(s)`);
110
+ }
111
+ }
112
+ }
113
+
114
+ return { valid: errors.length === 0, errors };
115
+ }
116
+
117
+ /**
118
+ * Load all entries from the learnings JSONL file.
119
+ * @param {string} [filePath] — defaults to GLOBAL_LEARNINGS_PATH
120
+ * @returns {object[]}
121
+ */
122
+ function loadAll(filePath) {
123
+ const target = filePath || GLOBAL_LEARNINGS_PATH;
124
+ if (!fs.existsSync(target)) {
125
+ return [];
126
+ }
127
+ const content = fs.readFileSync(target, 'utf8');
128
+ return content
129
+ .split('\n')
130
+ .filter(line => line.trim().length > 0)
131
+ .reduce((acc, line) => {
132
+ try {
133
+ acc.push(JSON.parse(line));
134
+ } catch (_e) {
135
+ console.error(`[learnings] Skipping malformed line: ${line.slice(0, 80)}`);
136
+ }
137
+ return acc;
138
+ }, []);
139
+ }
140
+
141
+ /**
142
+ * Save all entries to the learnings JSONL file.
143
+ * @param {object[]} entries
144
+ * @param {string} [filePath] — defaults to GLOBAL_LEARNINGS_PATH
145
+ */
146
+ function saveAll(entries, filePath) {
147
+ const target = filePath || GLOBAL_LEARNINGS_PATH;
148
+ const dir = path.dirname(target);
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ const content = entries.map(e => JSON.stringify(e)).join('\n') + (entries.length > 0 ? '\n' : '');
151
+ fs.writeFileSync(target, content, 'utf8');
152
+ }
153
+
154
+ /**
155
+ * Ingest a learning entry into the global store.
156
+ * Deduplicates by source_project + type + summary.
157
+ * If a duplicate is found, increments occurrences and updates confidence.
158
+ * @param {object} rawEntry
159
+ * @param {{ filePath?: string }} [options]
160
+ * @returns {{ action: 'created'|'updated', entry: object }}
161
+ */
162
+ function learningsIngest(rawEntry, options = {}) {
163
+ const filePath = options.filePath || GLOBAL_LEARNINGS_PATH;
164
+
165
+ // Fill in generated fields if missing
166
+ const entry = Object.assign({}, rawEntry);
167
+ if (!entry.id) {
168
+ try {
169
+ entry.id = crypto.randomUUID();
170
+ } catch (_e) {
171
+ entry.id = Date.now().toString(36) + Math.random().toString(36).slice(2);
172
+ }
173
+ }
174
+ if (!entry.created_at) {
175
+ entry.created_at = new Date().toISOString();
176
+ }
177
+
178
+ const existing = loadAll(filePath);
179
+
180
+ // Dedup check: same source_project + type + summary
181
+ const dupIndex = existing.findIndex(
182
+ e => e.source_project === entry.source_project &&
183
+ e.type === entry.type &&
184
+ e.summary === entry.summary
185
+ );
186
+
187
+ if (dupIndex !== -1) {
188
+ // Update existing entry
189
+ const dup = existing[dupIndex];
190
+ dup.occurrences = (dup.occurrences || 1) + 1;
191
+ dup.confidence = computeConfidence(dup.occurrences);
192
+ dup.updated_at = new Date().toISOString();
193
+ saveAll(existing, filePath);
194
+ return { action: 'updated', entry: dup };
195
+ }
196
+
197
+ // New entry — validate before saving
198
+ const validation = validateEntry(entry);
199
+ if (!validation.valid) {
200
+ throw new Error(`Invalid learning entry: ${validation.errors.join('; ')}`);
201
+ }
202
+
203
+ existing.push(entry);
204
+ saveAll(existing, filePath);
205
+ return { action: 'created', entry };
206
+ }
207
+
208
+ /**
209
+ * Query learnings with optional filters.
210
+ * @param {{ tags?: string[], minConfidence?: string, stack?: string, type?: string }} [filters]
211
+ * @param {{ filePath?: string }} [options]
212
+ * @returns {object[]} Matching entries sorted by occurrences descending
213
+ */
214
+ function learningsQuery(filters = {}, options = {}) {
215
+ const filePath = options.filePath || GLOBAL_LEARNINGS_PATH;
216
+ let entries = loadAll(filePath);
217
+
218
+ // Filter by tags (ALL listed tags must be present)
219
+ if (filters.tags && filters.tags.length > 0) {
220
+ entries = entries.filter(e => {
221
+ const entryTags = e.tags || [];
222
+ return filters.tags.every(t => entryTags.includes(t));
223
+ });
224
+ }
225
+
226
+ // Filter by minConfidence (entry confidence must be >= threshold)
227
+ if (filters.minConfidence && filters.minConfidence !== 'low') {
228
+ const minLevel = CONFIDENCE_TIERS[filters.minConfidence];
229
+ if (minLevel !== undefined) {
230
+ entries = entries.filter(e => {
231
+ const entryLevel = CONFIDENCE_TIERS[e.confidence] || 0;
232
+ return entryLevel >= minLevel;
233
+ });
234
+ }
235
+ }
236
+
237
+ // Filter by stack: matches if tags include "stack:<value>" OR stack_tags includes value
238
+ if (filters.stack) {
239
+ const stackTag = `stack:${filters.stack}`;
240
+ entries = entries.filter(e => {
241
+ const entryTags = e.tags || [];
242
+ const stackTags = e.stack_tags || [];
243
+ return entryTags.includes(stackTag) || stackTags.includes(filters.stack);
244
+ });
245
+ }
246
+
247
+ // Filter by type (exact match)
248
+ if (filters.type) {
249
+ entries = entries.filter(e => e.type === filters.type);
250
+ }
251
+
252
+ // Sort by occurrences descending
253
+ entries.sort((a, b) => (b.occurrences || 1) - (a.occurrences || 1));
254
+
255
+ return entries;
256
+ }
257
+
258
+ /**
259
+ * Check deferral thresholds against current learnings data.
260
+ * @param {{ filePath?: string }} [options]
261
+ * @returns {Array<{ key: string, trigger: string, message: string }>} Triggered thresholds
262
+ */
263
+ function checkDeferralThresholds(options = {}) {
264
+ const filePath = options.filePath || GLOBAL_LEARNINGS_PATH;
265
+ const entries = loadAll(filePath);
266
+
267
+ const totalCount = entries.length;
268
+
269
+ // Compute max occurrences for any single tag
270
+ const tagCounts = {};
271
+ for (const entry of entries) {
272
+ for (const tag of (entry.tags || [])) {
273
+ tagCounts[tag] = (tagCounts[tag] || 0) + (entry.occurrences || 1);
274
+ }
275
+ }
276
+ const tagMax = Object.values(tagCounts).reduce((max, v) => Math.max(max, v), 0);
277
+
278
+ // Count audit-type entries
279
+ const auditCount = entries.filter(e => e.type === 'planning-failure' || e.type === 'process-failure').length;
280
+
281
+ // Query count: default 0 (separate counter not implemented in v1 — threshold tracked externally)
282
+ const queryCount = 0;
283
+
284
+ const triggered = [];
285
+ for (const threshold of DEFERRAL_THRESHOLDS) {
286
+ if (threshold.check(totalCount, tagMax, auditCount, queryCount)) {
287
+ triggered.push({
288
+ key: threshold.key,
289
+ trigger: threshold.trigger,
290
+ message: `Deferral threshold met: ${threshold.key} (${threshold.trigger})`
291
+ });
292
+ }
293
+ }
294
+
295
+ return triggered;
296
+ }
297
+
298
+ // --- Exports ---
299
+
300
+ module.exports = {
301
+ GLOBAL_LEARNINGS_PATH,
302
+ LEARNING_TYPES,
303
+ CONFIDENCE_TIERS,
304
+ DEFERRAL_THRESHOLDS,
305
+ computeConfidence,
306
+ validateEntry,
307
+ loadAll,
308
+ saveAll,
309
+ learningsIngest,
310
+ learningsQuery,
311
+ checkDeferralThresholds
312
+ };