pan-wizard 3.5.2 → 3.8.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/README.md +28 -9
- package/agents/pan-executor.md +18 -0
- package/agents/pan-experiment-runner.md +126 -0
- package/agents/pan-phase-researcher.md +16 -0
- package/agents/pan-plan-checker.md +80 -0
- package/agents/pan-planner.md +19 -0
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +41 -0
- package/bin/install-lib.cjs +55 -0
- package/bin/install.js +71 -22
- package/commands/pan/debug.md +1 -1
- package/commands/pan/experiment.md +219 -0
- package/commands/pan/health.md +1 -1
- package/commands/pan/learn.md +15 -1
- package/commands/pan/links.md +102 -0
- package/commands/pan/optimize.md +13 -0
- package/commands/pan/patches.md +10 -1
- package/commands/pan/phase-tests.md +1 -4
- package/commands/pan/todo-add.md +1 -1
- package/commands/pan/todo-check.md +1 -1
- package/hooks/dist/pan-cost-logger.js +54 -4
- package/hooks/dist/pan-trace-logger.js +72 -3
- package/package.json +67 -66
- package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
- package/pan-wizard-core/bin/lib/commands.cjs +8 -0
- package/pan-wizard-core/bin/lib/config.cjs +13 -2
- package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
- package/pan-wizard-core/bin/lib/core.cjs +13 -0
- package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
- package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
- package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
- package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
- package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
- package/pan-wizard-core/bin/lib/experiment.cjs +502 -0
- package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
- package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
- package/pan-wizard-core/bin/lib/links.cjs +549 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
- package/pan-wizard-core/bin/lib/runner.cjs +473 -0
- package/pan-wizard-core/bin/lib/verify.cjs +23 -0
- package/pan-wizard-core/bin/pan-tools.cjs +247 -3
- package/pan-wizard-core/learnings/README.md +70 -0
- package/pan-wizard-core/learnings/index.json +540 -0
- package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
- package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
- package/pan-wizard-core/learnings/internal/external-research.md +93 -0
- package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
- package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
- package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
- package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
- package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
- package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
- package/pan-wizard-core/learnings/universal/composition.md +33 -0
- package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
- package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
- package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
- package/pan-wizard-core/learnings/universal/design-process.md +21 -0
- package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
- package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
- package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
- package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
- package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
- package/pan-wizard-core/learnings/universal/invariants.md +21 -0
- package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
- package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
- package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
- package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
- package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
- package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
- package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
- package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
- package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
- package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
- package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
- package/pan-wizard-core/learnings/universal/unicode.md +21 -0
- package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
- package/pan-wizard-core/references/guardrails.md +58 -0
- package/pan-wizard-core/references/handoff-decisions.md +156 -0
- package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
- package/pan-wizard-core/references/verification-patterns.md +31 -0
- package/pan-wizard-core/templates/config.json +2 -1
- package/pan-wizard-core/templates/idea.md +52 -0
- package/pan-wizard-core/templates/summary-complex.md +14 -5
- package/pan-wizard-core/templates/summary-minimal.md +6 -0
- package/pan-wizard-core/templates/summary-standard.md +14 -3
- package/pan-wizard-core/workflows/discuss-phase.md +108 -1
- package/pan-wizard-core/workflows/exec-phase.md +37 -1
- package/pan-wizard-core/workflows/execute-plan.md +14 -0
- package/pan-wizard-core/workflows/health.md +23 -0
- package/pan-wizard-core/workflows/new-project.md +65 -81
- package/pan-wizard-core/workflows/plan-phase.md +58 -0
- package/pan-wizard-core/workflows/transition.md +102 -7
- package/pan-wizard-core/workflows/verify-phase.md +14 -0
- package/scripts/build-hooks.js +7 -1
- package/scripts/generate-skills-docs.py +10 -8
- package/scripts/git-hooks/pre-commit +40 -0
- 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
|
};
|