pan-wizard 3.5.2 → 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 +8 -8
  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
@@ -48,7 +48,53 @@ function generateSessionId() {
48
48
  return 'sess_' + now.toISOString().replace(/[-:.Z]/g, '').slice(0, 15);
49
49
  }
50
50
 
51
+ // P-1404 helper: try to reuse a recent existing session.
52
+ // Returns { session_id, started_at, directory, reused: true } on success,
53
+ // null if no recent session exists or reuse failed.
54
+ function tryReuseSession(cwd, opts) {
55
+ try {
56
+ const optimizeDir = getOptimizeDir(cwd);
57
+ const currentSessionPath = path.join(optimizeDir, CURRENT_SESSION_FILE);
58
+ const existingId = fs.readFileSync(currentSessionPath, 'utf-8').trim();
59
+ if (!existingId) return null;
60
+
61
+ const sessionDir = path.join(getTracesDir(cwd), existingId);
62
+ const sessionMetaPath = path.join(sessionDir, OPT_SESSION_FILE);
63
+ const meta = JSON.parse(fs.readFileSync(sessionMetaPath, 'utf-8'));
64
+
65
+ // Don't reuse if session is already explicitly ended
66
+ if (meta.ended_at) return null;
67
+
68
+ // Don't reuse if session started more than REUSE_WINDOW_MS ago
69
+ const startedAt = new Date(meta.started_at).getTime();
70
+ if (Date.now() - startedAt > SESSION_REUSE_WINDOW_MS) return null;
71
+
72
+ return {
73
+ session_id: existingId,
74
+ started_at: meta.started_at,
75
+ directory: sessionDir,
76
+ reused: true,
77
+ };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // P-1404 fix (v3.7.3): a recent existing session (within REUSE_WINDOW_MS) is
84
+ // reused instead of creating a new one. Without this, every /pan:exec-phase /
85
+ // /pan:plan-phase / etc. creates its own session, fragmenting trace data
86
+ // across many sub-sessions and making /pan:learn analysis incomplete.
87
+ // Fragmentation surfaced by panloop run: 14 events scattered across 4 sessions.
88
+ const SESSION_REUSE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
89
+
51
90
  function initTraceSession(cwd, opts = {}) {
91
+ // Reuse logic: if no explicit sessionId requested AND a current-session
92
+ // file exists pointing at a recent session, reuse it.
93
+ if (!opts.sessionId && !opts.forceNew) {
94
+ const reused = tryReuseSession(cwd, opts);
95
+ if (reused) return reused;
96
+ }
97
+
52
98
  const sessionId = opts.sessionId || generateSessionId();
53
99
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
54
100
 
@@ -73,7 +119,7 @@ function initTraceSession(cwd, opts = {}) {
73
119
  fs.mkdirSync(optimizeDir, { recursive: true });
74
120
  fs.writeFileSync(path.join(optimizeDir, CURRENT_SESSION_FILE), sessionId + '\n');
75
121
 
76
- return { session_id: sessionId, started_at: meta.started_at, directory: sessionDir };
122
+ return { session_id: sessionId, started_at: meta.started_at, directory: sessionDir, reused: false };
77
123
  } catch (e) {
78
124
  return { error: e.message };
79
125
  }
@@ -299,6 +345,30 @@ function analyzeEvents(events, sessionMeta) {
299
345
 
300
346
  timing.token_data_available = events.some(e => e.context && (e.context.input_tokens || 0) > 0);
301
347
 
348
+ // P-1403 (v3.7.3): autonomous-overhead metrics. Useful as a trend signal —
349
+ // are autonomous runs getting cheaper/faster as patterns saturate? Caller
350
+ // can pass commitCount + costUsd via sessionMeta.commit_count /
351
+ // sessionMeta.cost_usd (read from harvest.json + claude-cli result JSON).
352
+ // When unavailable, fields are null (don't lie about absent data).
353
+ const overhead = {};
354
+ const commitCount = sessionMeta && typeof sessionMeta.commit_count === 'number'
355
+ ? sessionMeta.commit_count
356
+ : null;
357
+ const costUsd = sessionMeta && typeof sessionMeta.cost_usd === 'number'
358
+ ? sessionMeta.cost_usd
359
+ : null;
360
+ const durationMs = timing.session_duration_ms || null;
361
+
362
+ if (commitCount != null && durationMs) {
363
+ overhead.commits_per_minute = Math.round((commitCount / (durationMs / 60000)) * 100) / 100;
364
+ overhead.minutes_per_commit = Math.round((durationMs / 60000 / commitCount) * 100) / 100;
365
+ }
366
+ if (costUsd != null && commitCount != null && commitCount > 0) {
367
+ overhead.cost_usd_per_commit = Math.round((costUsd / commitCount) * 100) / 100;
368
+ }
369
+ if (costUsd != null) overhead.total_cost_usd = costUsd;
370
+ if (commitCount != null) overhead.commit_count = commitCount;
371
+
302
372
  return {
303
373
  summary: {
304
374
  total_events: events.length,
@@ -313,6 +383,7 @@ function analyzeEvents(events, sessionMeta) {
313
383
  memory_primed_count: memoryPrimed.length,
314
384
  },
315
385
  timing,
386
+ overhead,
316
387
  error_patterns: frequencyMap(errors),
317
388
  gap_patterns: frequencyMap(gaps),
318
389
  memory_miss_patterns: frequencyMap(memoryMisses),
@@ -612,6 +683,402 @@ function cmdOptimizeList(cwd, raw) {
612
683
 
613
684
  // ─── Exports ─────────────────────────────────────────────────────────────────
614
685
 
686
+ // ─── W4: Promote (self-improvement loop) ──────────────────────────────────────
687
+ //
688
+ // Spec: docs/specs/self_improvement_loop_featureai.md §3.2 W4
689
+ //
690
+ // Promote a finding from a harvested experiment into the shipped behavioral
691
+ // surface. Two scopes:
692
+ // - universal: ships to all 5 runtime installs (consumed by user-project workflows)
693
+ // - internal: source-only (consumed when working on PAN itself)
694
+ //
695
+ // Each topic file is markdown with YAML frontmatter listing its pattern IDs.
696
+ // The promote step is manual — the human running pan-tools picks scope/topic.
697
+ // Auto-promote (rules-based, AI-confidence threshold) is deferred to v3.8+.
698
+
699
+ const VALID_SCOPES = ['universal', 'internal'];
700
+ // Topic name: lowercase, digits, hyphens; max 40 chars; no leading/trailing hyphen.
701
+ // Same rules as experiment slug (intentional — symmetric naming).
702
+ const TOPIC_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
703
+
704
+ function getLearningsDir(sourceRoot, scope) {
705
+ return path.join(sourceRoot, 'pan-wizard-core', 'learnings', scope);
706
+ }
707
+
708
+ function getTopicFilePath(sourceRoot, scope, topic) {
709
+ return path.join(getLearningsDir(sourceRoot, scope), `${topic}.md`);
710
+ }
711
+
712
+ function validatePromoteInputs(pattern, opts) {
713
+ if (!opts || typeof opts !== 'object') return 'opts is required';
714
+ if (!VALID_SCOPES.includes(opts.scope)) {
715
+ return `scope must be one of: ${VALID_SCOPES.join(', ')}, got "${opts.scope}"`;
716
+ }
717
+ if (typeof opts.topic !== 'string' || !TOPIC_RE.test(opts.topic)) {
718
+ return 'topic invalid: must be lowercase letters, digits, hyphens (no path separators or leading/trailing hyphen)';
719
+ }
720
+ if (!opts.sourceRoot) return 'sourceRoot is required';
721
+ if (!pattern || typeof pattern !== 'object') return 'pattern is required';
722
+ if (!pattern.id) return 'pattern.id is required';
723
+ if (!pattern.summary) return 'pattern.summary is required';
724
+ if (!pattern.rule) return 'pattern.rule is required';
725
+ return null;
726
+ }
727
+
728
+ /**
729
+ * Classify whether a pattern looks STRUCTURAL (generalizes across models /
730
+ * languages / runtimes) or PROMPT-FRAGMENT (specific phrasing that doesn't
731
+ * generalize). Per P-RES-007 (Sakana DGM, 2025): structural changes
732
+ * transferred across models in self-improvement loops; prompt-fragment
733
+ * tweaks did not. Universal scope should be reserved for structural
734
+ * patterns; prompt fragments belong in internal scope at most.
735
+ *
736
+ * This is HEURISTIC. Returns { kind: 'structural'|'prompt-fragment'|'unclear',
737
+ * reasons: [...] } — never definitive. Used to surface a WARNING on
738
+ * `learn promote --scope universal` so the human gate has a signal.
739
+ *
740
+ * @param {object} pattern - { rule, summary, ... }
741
+ * @returns {object} { kind, reasons: string[] }
742
+ */
743
+ function classifyPatternKind(pattern) {
744
+ const rule = String(pattern.rule || '').toLowerCase();
745
+ const summary = String(pattern.summary || '').toLowerCase();
746
+ const combined = rule + ' ' + summary;
747
+ const reasons = [];
748
+
749
+ // Strong structural markers — describe SHAPES, contracts, file/module patterns
750
+ const STRUCTURAL_RE = [
751
+ /\b(pattern|structure|architecture|module|interface|contract|api|signature|schema|invariant)\b/,
752
+ /\b(wrap|factor|compose|extract|encapsulate|inject)\b/,
753
+ /\b(closure|callback|generator|stream|state\s+machine)\b/,
754
+ /file:\/\/|\.md\b|\.cjs\b|\.js\b|\.ts\b/,
755
+ /\b(workflow|step|phase|gate|hook)\b/,
756
+ ];
757
+ let structuralHits = 0;
758
+ for (const re of STRUCTURAL_RE) {
759
+ if (re.test(combined)) structuralHits++;
760
+ }
761
+
762
+ // Prompt-fragment markers — specific phrasing, "always say X", quoted strings.
763
+ // Anchored carefully: "write" alone is too broad (matches "write to file"),
764
+ // so we require co-occurrence with "the words"/"exact"/quoted text.
765
+ const PROMPT_RE = [
766
+ /\b(say|write)\s+(the\s+exact|the\s+words?|"|')/,
767
+ /\buse\s+the\s+(exact|words?)\b/,
768
+ /\b(prepend|prefix\s+with)\b/,
769
+ /\balways\s+include\b/,
770
+ /\bnever\s+say\b/,
771
+ /\bphras(e|ing)\b/,
772
+ ];
773
+ let promptHits = 0;
774
+ for (const re of PROMPT_RE) {
775
+ if (re.test(combined)) promptHits++;
776
+ }
777
+
778
+ // Length heuristic — structural patterns need elaboration; very short rules are
779
+ // either trivial or prompt fragments.
780
+ const ruleLen = (pattern.rule || '').length;
781
+
782
+ if (structuralHits >= 2 && promptHits === 0) {
783
+ reasons.push(`structural markers: ${structuralHits} hit(s)`);
784
+ return { kind: 'structural', reasons };
785
+ }
786
+ if (promptHits >= 1 && structuralHits < 2) {
787
+ reasons.push(`prompt-fragment markers: ${promptHits} hit(s)`);
788
+ if (ruleLen < 200) reasons.push(`short rule (${ruleLen} chars) — typical of prompt tweaks`);
789
+ return { kind: 'prompt-fragment', reasons };
790
+ }
791
+ if (ruleLen < 100) {
792
+ reasons.push(`very short rule (${ruleLen} chars) — likely too narrow to generalize`);
793
+ return { kind: 'prompt-fragment', reasons };
794
+ }
795
+ reasons.push('no clear signal either way');
796
+ return { kind: 'unclear', reasons };
797
+ }
798
+
799
+ /**
800
+ * Parse a topic file: returns { frontmatter, body, patterns }.
801
+ * frontmatter is the parsed YAML-ish (we use a minimal parser since files are
802
+ * always written by us — no general YAML dependency needed).
803
+ */
804
+ function readTopicFile(filePath) {
805
+ const content = fs.readFileSync(filePath, 'utf-8');
806
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
807
+ if (!fmMatch) {
808
+ return { frontmatter: { topic: '', patterns: [] }, body: content, _raw: content };
809
+ }
810
+ const fmText = fmMatch[1];
811
+ const body = fmMatch[2];
812
+ const fm = parseSimpleFrontmatter(fmText);
813
+ return { frontmatter: fm, body, _raw: content };
814
+ }
815
+
816
+ /**
817
+ * Parse our own structured frontmatter shape:
818
+ * topic: <name>
819
+ * last_updated: <ISO>
820
+ * patterns:
821
+ * - id: P-001
822
+ * summary: ...
823
+ * promoted_at: ...
824
+ * source_experiments: [a, b]
825
+ */
826
+ function parseSimpleFrontmatter(text) {
827
+ const out = { topic: '', last_updated: '', patterns: [] };
828
+ const lines = text.split('\n');
829
+ let inPatterns = false;
830
+ let current = null;
831
+ for (const line of lines) {
832
+ if (line === 'patterns:') { inPatterns = true; continue; }
833
+ if (!inPatterns) {
834
+ const m = line.match(/^([a-z_]+):\s*(.*)$/);
835
+ if (m) out[m[1]] = m[2].trim();
836
+ continue;
837
+ }
838
+ // Inside patterns
839
+ if (line.startsWith(' - id:')) {
840
+ if (current) out.patterns.push(current);
841
+ current = { id: line.replace(/^\s*- id:\s*/, '').trim() };
842
+ } else if (current) {
843
+ const m = line.match(/^\s+([a-z_]+):\s*(.*)$/);
844
+ if (m) {
845
+ const key = m[1];
846
+ let val = m[2].trim();
847
+ if (val.startsWith('[') && val.endsWith(']')) {
848
+ val = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
849
+ }
850
+ current[key] = val;
851
+ }
852
+ }
853
+ }
854
+ if (current) out.patterns.push(current);
855
+ return out;
856
+ }
857
+
858
+ function serializeTopicFile(topic, patterns, body) {
859
+ const ts = new Date().toISOString();
860
+ let fm = `topic: ${topic}\nlast_updated: ${ts}\n`;
861
+ fm += `patterns:\n`;
862
+ for (const p of patterns) {
863
+ fm += ` - id: ${p.id}\n`;
864
+ fm += ` summary: ${(p.summary || '').replace(/\n/g, ' ')}\n`;
865
+ fm += ` promoted_at: ${p.promoted_at || ts}\n`;
866
+ const srcExps = Array.isArray(p.source_experiments) ? p.source_experiments : [];
867
+ fm += ` source_experiments: [${srcExps.join(', ')}]\n`;
868
+ }
869
+ return `---\n${fm}---\n${body}`;
870
+ }
871
+
872
+ function buildPatternBody(pattern) {
873
+ const applies = pattern.applies_in || '';
874
+ return [
875
+ ``,
876
+ `## ${pattern.id} — ${pattern.summary}`,
877
+ ``,
878
+ `**Evidence:** ${pattern.evidence || '(no evidence captured)'}`,
879
+ ``,
880
+ `**Rule:** ${pattern.rule}`,
881
+ ``,
882
+ applies ? `**Applies in:** ${applies}\n` : '',
883
+ ].join('\n');
884
+ }
885
+
886
+ /**
887
+ * Promote a pattern into a topic file under learnings/{scope}/{topic}.md.
888
+ *
889
+ * @param {object} pattern - { id, summary, evidence, rule, applies_in?, source_experiments? }
890
+ * @param {object} opts
891
+ * @param {string} opts.scope - 'universal' | 'internal'
892
+ * @param {string} opts.topic - topic file name (no .md extension)
893
+ * @param {string} opts.sourceRoot - PAN source repo root
894
+ * @returns {object} { promoted_to, pattern_id, scope, topic } or { error }
895
+ */
896
+ function promotePattern(pattern, opts) {
897
+ const validationError = validatePromoteInputs(pattern, opts);
898
+ if (validationError) return { error: validationError };
899
+
900
+ const { scope, topic, sourceRoot } = opts;
901
+ const learningsDir = getLearningsDir(sourceRoot, scope);
902
+
903
+ // P-RES-007 gate: warn (don't block) when a pattern that looks like a
904
+ // prompt fragment is being promoted to UNIVERSAL scope. Prompt fragments
905
+ // don't generalize across models/runtimes per Sakana DGM (2025); they
906
+ // should stay in internal scope. The check is HEURISTIC — final call is
907
+ // still the human's. We attach the warning to the result object.
908
+ let scopeWarning = null;
909
+ if (scope === 'universal') {
910
+ const classification = classifyPatternKind(pattern);
911
+ if (classification.kind === 'prompt-fragment') {
912
+ scopeWarning = {
913
+ code: 'P-RES-007',
914
+ kind: classification.kind,
915
+ message: `Pattern looks like a prompt-fragment (specific phrasing) rather than a structural pattern. Per P-RES-007 (Sakana DGM, 2025), prompt tweaks don't generalize across models — universal scope should be reserved for structural changes. Consider --scope internal instead, or reword the rule to describe the SHAPE, not the WORDS.`,
916
+ reasons: classification.reasons,
917
+ };
918
+ }
919
+ }
920
+
921
+ try {
922
+ fs.mkdirSync(learningsDir, { recursive: true });
923
+ } catch (err) {
924
+ return { error: `failed to ensure learnings dir: ${err.message}` };
925
+ }
926
+
927
+ const filePath = getTopicFilePath(sourceRoot, scope, topic);
928
+ const fileExists = fs.existsSync(filePath);
929
+
930
+ let frontmatter, body;
931
+ if (fileExists) {
932
+ const parsed = readTopicFile(filePath);
933
+ frontmatter = parsed.frontmatter;
934
+ body = parsed.body;
935
+
936
+ // Refuse duplicate pattern id
937
+ if (frontmatter.patterns.some(p => p.id === pattern.id)) {
938
+ return { error: `pattern "${pattern.id}" is already promoted in topic "${topic}"` };
939
+ }
940
+ } else {
941
+ frontmatter = { topic, last_updated: '', patterns: [] };
942
+ body = `\n# ${capitalize(topic.replace(/-/g, ' '))} (AI-derived)\n\n` +
943
+ `> Auto-maintained by \`pan-tools learn promote\`. Each pattern was extracted ` +
944
+ `from one or more experiment runs (see source_experiments). Patterns are ` +
945
+ `**advisory** — orchestrators should weight them against current context.\n`;
946
+ }
947
+
948
+ // Append pattern to body
949
+ body += buildPatternBody(pattern);
950
+
951
+ // Append to frontmatter pattern list
952
+ const promotedAt = new Date().toISOString();
953
+ frontmatter.patterns.push({
954
+ id: pattern.id,
955
+ summary: pattern.summary,
956
+ promoted_at: promotedAt,
957
+ source_experiments: pattern.source_experiments || [],
958
+ });
959
+
960
+ const serialized = serializeTopicFile(topic, frontmatter.patterns, body);
961
+
962
+ try {
963
+ fs.writeFileSync(filePath, serialized);
964
+ } catch (err) {
965
+ return { error: `failed to write topic file: ${err.message}` };
966
+ }
967
+
968
+ const result = {
969
+ promoted_to: filePath,
970
+ pattern_id: pattern.id,
971
+ scope,
972
+ topic,
973
+ promoted_at: promotedAt,
974
+ };
975
+ if (scopeWarning) result.warning = scopeWarning;
976
+ return result;
977
+ }
978
+
979
+ function capitalize(s) {
980
+ return s.split(' ').map(w => w[0] ? w[0].toUpperCase() + w.slice(1) : w).join(' ');
981
+ }
982
+
983
+ /**
984
+ * Walk both learnings tiers and return an inventory of all promoted patterns.
985
+ *
986
+ * @param {object} opts
987
+ * @param {string} opts.sourceRoot
988
+ * @returns {object} { universal: [...], internal: [...], total }
989
+ */
990
+ function listPromotedPatterns(opts = {}) {
991
+ const sourceRoot = opts.sourceRoot;
992
+ if (!sourceRoot) return { error: 'sourceRoot is required' };
993
+
994
+ const result = { universal: [], internal: [], total: 0 };
995
+ for (const scope of VALID_SCOPES) {
996
+ const dir = getLearningsDir(sourceRoot, scope);
997
+ if (!fs.existsSync(dir)) continue;
998
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'README.md');
999
+ for (const file of files) {
1000
+ const filePath = path.join(dir, file);
1001
+ try {
1002
+ const parsed = readTopicFile(filePath);
1003
+ const topicName = file.replace(/\.md$/, '');
1004
+ for (const p of parsed.frontmatter.patterns) {
1005
+ result[scope].push({
1006
+ id: p.id,
1007
+ summary: p.summary,
1008
+ scope,
1009
+ topic: topicName,
1010
+ promoted_at: p.promoted_at,
1011
+ source_experiments: p.source_experiments || [],
1012
+ });
1013
+ }
1014
+ } catch {
1015
+ // Skip malformed files
1016
+ }
1017
+ }
1018
+ }
1019
+ result.total = result.universal.length + result.internal.length;
1020
+ return result;
1021
+ }
1022
+
1023
+ /**
1024
+ * Remove a previously-promoted pattern from a topic file. If the topic file
1025
+ * has no patterns left after removal, the file is deleted entirely.
1026
+ *
1027
+ * @param {string} patternId
1028
+ * @param {object} opts - { scope, topic, sourceRoot }
1029
+ */
1030
+ function unpromotePattern(patternId, opts) {
1031
+ if (!opts || !VALID_SCOPES.includes(opts.scope)) {
1032
+ return { error: `scope must be one of: ${VALID_SCOPES.join(', ')}` };
1033
+ }
1034
+ if (!opts.topic || !TOPIC_RE.test(opts.topic)) {
1035
+ return { error: 'topic invalid' };
1036
+ }
1037
+ if (!opts.sourceRoot) return { error: 'sourceRoot is required' };
1038
+ if (!patternId) return { error: 'patternId is required' };
1039
+
1040
+ const filePath = getTopicFilePath(opts.sourceRoot, opts.scope, opts.topic);
1041
+ if (!fs.existsSync(filePath)) {
1042
+ return { error: `topic file not found: ${opts.topic}` };
1043
+ }
1044
+
1045
+ const parsed = readTopicFile(filePath);
1046
+ const before = parsed.frontmatter.patterns.length;
1047
+ parsed.frontmatter.patterns = parsed.frontmatter.patterns.filter(p => p.id !== patternId);
1048
+ if (parsed.frontmatter.patterns.length === before) {
1049
+ return { error: `pattern "${patternId}" not found in topic "${opts.topic}"` };
1050
+ }
1051
+
1052
+ // Strip the pattern's body section. Pattern body is a `## P-<id> — ...` heading
1053
+ // followed by content until the next `## ` or end-of-file.
1054
+ const headingRe = new RegExp(
1055
+ `\\n## ${patternId.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
1056
+ ''
1057
+ );
1058
+ const newBody = parsed.body.replace(headingRe, '');
1059
+
1060
+ // If no patterns left, remove the topic file entirely
1061
+ if (parsed.frontmatter.patterns.length === 0) {
1062
+ try {
1063
+ fs.unlinkSync(filePath);
1064
+ } catch (err) {
1065
+ return { error: `failed to delete empty topic file: ${err.message}` };
1066
+ }
1067
+ return { removed: patternId, file_deleted: true };
1068
+ }
1069
+
1070
+ const serialized = serializeTopicFile(opts.topic, parsed.frontmatter.patterns, newBody);
1071
+ try {
1072
+ fs.writeFileSync(filePath, serialized);
1073
+ } catch (err) {
1074
+ return { error: `failed to write topic file: ${err.message}` };
1075
+ }
1076
+
1077
+ return { removed: patternId, file_deleted: false };
1078
+ }
1079
+
1080
+ // ─── End W4 promote ──────────────────────────────────────────────────────────
1081
+
615
1082
  module.exports = {
616
1083
  // Session management
617
1084
  initTraceSession,
@@ -640,6 +1107,11 @@ module.exports = {
640
1107
  getOptimizeDir,
641
1108
  getTracesDir,
642
1109
  getReportsDir,
1110
+ // W4: Self-improvement loop promote
1111
+ promotePattern,
1112
+ listPromotedPatterns,
1113
+ unpromotePattern,
1114
+ classifyPatternKind, // P-RES-007 (v3.7.10)
643
1115
  // Constants (exported for hook + tests)
644
1116
  OPTIMIZE_DIR,
645
1117
  TRACES_DIR,
@@ -650,4 +1122,5 @@ module.exports = {
650
1122
  CURRENT_SESSION_FILE,
651
1123
  EVENT_TYPES,
652
1124
  IMPACT_LEVELS,
1125
+ VALID_SCOPES,
653
1126
  };