@aikdna/studio-core 0.3.0 → 0.4.1

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.
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Enhanced Quality Gates — 4-grade readiness scoring with detailed rules.
2
+ * Enhanced Quality Gates — 4-grade readiness with integrated card validation.
3
3
  *
4
4
  * Grades:
5
5
  * draft_grade — Core+Patterns exist, ≥3 human-reviewed cards
6
- * human_controlled — All core axioms locked, each with applies_when/does_not_apply_when/failure_risk
7
- * tested_grade — ≥5 eval cases, ≥3 comparison tests
8
- * publishable_grade — ≥10 evals, README complete, known limitations, kdna verify passes
6
+ * human_controlled — All core axioms locked with boundaries, ≥50% have Feynman
7
+ * tested_grade — ≥5 rated evals, ≥3 comparison tests
8
+ * publishable_grade — ≥10 evals, all axioms have Feynman, README 4 questions, no blocking
9
+ *
10
+ * v0.3.2: integrates validateAllCards, Feynman enforcement at publishable grade.
9
11
  */
12
+
10
13
  const contradiction = require('./contradiction');
14
+ const { validateAllCards } = require('./validate-cards');
11
15
 
12
16
  function computeReadiness(project) {
13
17
  const cards = project.cards || [];
@@ -15,131 +19,68 @@ function computeReadiness(project) {
15
19
  const locked = cards.filter(c => c.locked);
16
20
  const lockedAxioms = locked.filter(c => c.type === 'axiom');
17
21
  const lockedSelfChecks = locked.filter(c => c.type === 'self_check');
22
+ const lockedMisunderstandings = locked.filter(c => c.type === 'misunderstanding');
18
23
  const ratedTests = tests.filter(t => t.result);
19
24
 
20
25
  const blocking = [];
21
26
  const warnings = [];
22
27
 
23
- // ── Minimum Structure ──────────────────────────────────────────
24
- if (project.cards.length === 0) {
25
- blocking.push('Project has no cards');
26
- return buildResult('draft_grade', blocking, warnings, project);
27
- }
28
- if (locked.length === 0) {
29
- blocking.push('No locked cards — nothing to compile');
30
- return buildResult('draft_grade', blocking, warnings, project);
28
+ // ── Card validation integration (v0.3.2) ─────────────────────────
29
+ const cardResults = validateAllCards(project);
30
+ for (const { card_id, issues } of cardResults) {
31
+ for (const issue of issues) {
32
+ if (issue.severity === 'blocking') blocking.push(`${card_id}: ${issue.message}`);
33
+ else warnings.push(`${card_id}: ${issue.message}`);
34
+ }
31
35
  }
32
36
 
37
+ // ── Minimum Structure ──────────────────────────────────────────
38
+ if (cards.length === 0) { blocking.push('Project has no cards'); return buildResult('draft_grade', blocking, warnings, project); }
39
+ if (locked.length === 0) { blocking.push('No locked cards — nothing to compile'); return buildResult('draft_grade', blocking, warnings, project); }
40
+
33
41
  // ── Axiom Checks ──────────────────────────────────────────────
34
42
  for (const ax of lockedAxioms) {
35
- if (!ax.fields?.one_sentence || ax.fields.one_sentence.length < 10) {
36
- blocking.push(`${ax.id}: one_sentence too short or missing`);
37
- }
38
- if (!ax.fields?.full_statement || ax.fields.full_statement.length < 30) {
39
- warnings.push(`${ax.id}: full_statement too short — may be vague`);
40
- }
41
- if (!ax.fields?.why || ax.fields.why.length < 10) {
42
- warnings.push(`${ax.id}: missing "why" — explains what the agent gets wrong without this`);
43
- }
44
- if (!ax.fields?.applies_when || ax.fields.applies_when.length === 0) {
45
- blocking.push(`${ax.id}: missing applies_when`);
46
- }
47
- if (!ax.fields?.does_not_apply_when || ax.fields.does_not_apply_when.length === 0) {
48
- blocking.push(`${ax.id}: missing does_not_apply_when`);
49
- }
50
- if (!ax.fields?.failure_risk) {
51
- blocking.push(`${ax.id}: missing failure_risk`);
52
- }
53
- if (!ax.human_lock) {
54
- blocking.push(`${ax.id}: not locked — must be locked before compile`);
55
- }
56
- if (!ax.feynman_restatement) {
57
- warnings.push(`${ax.id}: missing Feynman restatement`);
58
- }
59
- }
60
-
61
- // ── Self-check Checks ──────────────────────────────────────────
62
- for (const sc of lockedSelfChecks) {
63
- const q = sc.fields?.question || '';
64
- if (!q.endsWith('?')) {
65
- blocking.push(`${sc.id}: self_check must be a question ending with ?`);
66
- }
67
- if (q.length < 15) {
68
- warnings.push(`${sc.id}: self_check question too short — may be too vague`);
69
- }
70
- if (/\b(is this good|is this correct|is this helpful|is this clear|good enough)\b/i.test(q)) {
71
- warnings.push(`${sc.id}: self_check is generic — should be domain-specific`);
72
- }
43
+ if (!ax.fields?.one_sentence || ax.fields.one_sentence.length < 10) blocking.push(`${ax.id}: one_sentence too short`);
44
+ if (!ax.fields?.applies_when?.length) blocking.push(`${ax.id}: missing applies_when`);
45
+ if (!ax.fields?.does_not_apply_when?.length) blocking.push(`${ax.id}: missing does_not_apply_when`);
46
+ if (!ax.fields?.failure_risk) blocking.push(`${ax.id}: missing failure_risk`);
47
+ if (!ax.human_lock) blocking.push(`${ax.id}: not locked`);
48
+ if (!ax.feynman_restatement) warnings.push(`${ax.id}: missing Feynman restatement`);
73
49
  }
74
50
 
75
51
  // ── Misunderstanding Checks ────────────────────────────────────
76
- const lockedMisunderstandings = locked.filter(c => c.type === 'misunderstanding');
77
52
  for (const ms of lockedMisunderstandings) {
78
- if (!ms.fields?.key_distinction || ms.fields.key_distinction.length < 20) {
79
- blocking.push(`${ms.id}: key_distinction missing or too short`);
80
- }
81
- if (!ms.fields?.wrong || ms.fields.wrong.length < 10) {
82
- warnings.push(`${ms.id}: wrong belief very short — may be a straw man`);
83
- }
84
- if (!ms.fields?.correct || ms.fields.correct.length < 10) {
85
- warnings.push(`${ms.id}: correct belief very short`);
86
- }
53
+ if (!ms.fields?.key_distinction || ms.fields.key_distinction.length < 20) blocking.push(`${ms.id}: key_distinction too short`);
87
54
  }
88
55
 
89
- // ── Boundary Checks ────────────────────────────────────────────
90
- const lockedBoundaries = locked.filter(c => c.type === 'boundary');
91
- for (const bd of lockedBoundaries) {
92
- if (bd.fields?.acceptable_exceptions && bd.fields.acceptable_exceptions.length === 0) {
93
- warnings.push(`${bd.id}: no acceptable_exceptions — every boundary has justified exceptions`);
94
- }
56
+ // ── Self-check Checks ──────────────────────────────────────────
57
+ for (const sc of lockedSelfChecks) {
58
+ const q = sc.fields?.question || '';
59
+ if (!q.endsWith('?')) blocking.push(`${sc.id}: self_check must end with ?`);
95
60
  }
96
61
 
97
62
  // ── Contradiction Check ────────────────────────────────────────
98
- const contradictions = contradiction.detectContradictions(cards);
99
- for (const c of contradictions) {
100
- if (c.severity === 'blocking') blocking.push(c.message);
101
- else warnings.push(c.message);
102
- }
103
-
104
- // ── Test Count Checks ──────────────────────────────────────────
105
- if (ratedTests.length === 0 && locked.length >= 3) {
106
- warnings.push('No rated tests — domain may not actually change agent behavior');
107
- }
108
- if (ratedTests.length < 3 && ratedTests.length > 0) {
109
- warnings.push(`Only ${ratedTests.length} rated tests — recommend at least 3 for confidence`);
63
+ for (const c of contradiction.detectContradictions(cards)) {
64
+ (c.severity === 'blocking' ? blocking : warnings).push(c.message);
110
65
  }
111
66
 
112
67
  // ── Determine Grade ────────────────────────────────────────────
113
68
  const axiomsComplete = lockedAxioms.length >= 1 &&
114
- lockedAxioms.every(ax =>
115
- ax.fields?.applies_when?.length &&
116
- ax.fields?.does_not_apply_when?.length &&
117
- ax.fields?.failure_risk &&
118
- ax.human_lock
119
- );
120
-
121
- const boundariesComplete = lockedBoundaries.length === 0 ||
122
- lockedBoundaries.every(b => b.fields?.scope && b.fields?.out_of_scope);
69
+ lockedAxioms.every(ax => ax.fields?.applies_when?.length && ax.fields?.does_not_apply_when?.length && ax.fields?.failure_risk && ax.human_lock);
70
+ const feynmanRatio = lockedAxioms.length > 0 ? lockedAxioms.filter(ax => ax.feynman_restatement).length / lockedAxioms.length : 0;
71
+ const allFeynman = lockedAxioms.every(ax => ax.feynman_restatement) && lockedMisunderstandings.every(ms => !ms.locked || ms.feynman_restatement);
123
72
 
124
73
  let grade = 'draft_grade';
125
- if (locked.length >= 3 && axiomsComplete) {
126
- grade = 'human_controlled';
127
- }
128
- if (grade === 'human_controlled' && ratedTests.length >= 5 && lockedSelfChecks.length >= 3 && boundariesComplete) {
129
- grade = 'tested_grade';
130
- }
131
- if (grade === 'tested_grade' &&
132
- ratedTests.length >= 10 &&
133
- lockedAxioms.length >= 3 &&
134
- lockedSelfChecks.length >= 5 &&
135
- blocking.length === 0) {
74
+ if (locked.length >= 3 && axiomsComplete && feynmanRatio >= 0.5) grade = 'human_controlled';
75
+ if (grade === 'human_controlled' && ratedTests.length >= 5 && lockedSelfChecks.length >= 3) grade = 'tested_grade';
76
+ if (grade === 'tested_grade' && ratedTests.length >= 10 && lockedAxioms.length >= 3 && lockedSelfChecks.length >= 5 && blocking.length === 0 && allFeynman) {
136
77
  grade = 'publishable_grade';
137
78
  }
138
79
 
139
- return buildResult(grade, blocking, warnings, project);
80
+ return buildResult(grade, blocking, warnings, project, { feynmanRatio, allFeynman });
140
81
  }
141
82
 
142
- function buildResult(grade, blocking, warnings, project) {
83
+ function buildResult(grade, blocking, warnings, project, detail = {}) {
143
84
  const lockedCount = (project.cards || []).filter(c => c.locked).length;
144
85
  const ratedTests = (project.tests || []).filter(t => t.result).length;
145
86
 
@@ -156,20 +97,15 @@ function buildResult(grade, blocking, warnings, project) {
156
97
  locked_self_checks: (project.cards || []).filter(c => c.type === 'self_check' && c.locked).length,
157
98
  total_tests: (project.tests || []).length,
158
99
  rated_tests: ratedTests,
100
+ feynman_ratio: detail.feynmanRatio !== undefined ? Math.round(detail.feynmanRatio * 100) + '%' : 'N/A',
159
101
  },
160
- next_step: grade === 'draft_grade'
161
- ? 'Lock at least 3 axioms with applies_when, does_not_apply_when, and failure_risk.'
162
- : grade === 'human_controlled'
163
- ? 'Add 5+ eval cases and run kdna compare to reach tested grade.'
164
- : grade === 'tested_grade'
165
- ? 'Add 10+ evals, 3+ axioms, 5+ self-checks, and pass kdna verify --judgment to reach publishable.'
166
- : 'Ready to publish. Run kdna pack and kdna publish.',
102
+ next_step: grade === 'draft_grade' ? 'Lock at least 3 axioms with boundaries and 50% Feynman.' :
103
+ grade === 'human_controlled' ? 'Add 5+ rated evals and 3+ self-checks.' :
104
+ grade === 'tested_grade' ? 'Add 10+ evals, complete Feynman on all axioms/misunderstandings, resolve all blocking issues.' :
105
+ 'Ready to publish. Run kdna pack and kdna publish.',
167
106
  };
168
107
  }
169
108
 
170
- function getBlockingIssues(project) {
171
- const result = computeReadiness(project);
172
- return result.blocking;
173
- }
109
+ function getBlockingIssues(project) { return computeReadiness(project).blocking; }
174
110
 
175
111
  module.exports = { computeReadiness, getBlockingIssues };
@@ -128,33 +128,66 @@ function compareDeltas(delta1, delta2) {
128
128
 
129
129
  function formatDeltaMarkdown(delta) {
130
130
  const lines = [];
131
- lines.push('# KDNA Judgment Comparison Report');
132
- lines.push('');
131
+ lines.push('# KDNA Judgment Comparison Report'); lines.push('');
133
132
  lines.push(`**Domain:** ${delta.meta.domain}`);
134
133
  lines.push(`**Model:** ${delta.meta.model}`);
135
- lines.push(`**Date:** ${delta.meta.timestamp}`);
136
- lines.push('');
137
- lines.push('## Judgment Diff');
138
- lines.push('');
139
- lines.push('| Dimension | Change |');
140
- lines.push('|-----------|--------|');
141
- for (const d of delta.changed_dimensions) {
142
- lines.push(`| ${d.axis} | **Changed**: ${d.value} |`);
143
- }
144
- if (delta.changed_dimensions.length === 0) {
145
- lines.push('| (none) | No significant change |');
146
- }
147
- lines.push('');
148
- lines.push('## Scoring');
149
- lines.push('');
150
- for (const [dim, value] of Object.entries(delta.scoring)) {
151
- lines.push(`- **${dim}:** ${value}`);
152
- }
134
+ lines.push(`**Date:** ${delta.meta.timestamp}`); lines.push('');
135
+ lines.push('## Judgment Diff'); lines.push('');
136
+ lines.push('| Dimension | Change |'); lines.push('|-----------|--------|');
137
+ for (const d of delta.changed_dimensions) lines.push(`| ${d.axis} | **Changed**: ${d.value} |`);
138
+ if (!delta.changed_dimensions.length) lines.push('| (none) | No significant change |');
153
139
  lines.push('');
154
- lines.push(`**Verdict:** ${delta.verdict.replace(/_/g, ' ')}`);
140
+ lines.push('## Scoring'); lines.push('');
141
+ for (const [dim, value] of Object.entries(delta.scoring)) lines.push(`- **${dim}:** ${value}`);
155
142
  lines.push('');
143
+ lines.push(`**Verdict:** ${delta.verdict.replace(/_/g, ' ')}`); lines.push('');
156
144
  lines.push(delta.summary);
157
145
  return lines.join('\n');
158
146
  }
159
147
 
160
- module.exports = { parseCompareOutput, scoreDelta, createJudgmentDelta, compareDeltas, formatDeltaMarkdown };
148
+ // ─── JSON report parsing (v0.3.3) ─────────────────────────────────────
149
+
150
+ function parseCompareReportJson(report) {
151
+ if (!report || !report.diff) return { axes: {}, verdict: 'trajectory_unchanged' };
152
+
153
+ const axes = {};
154
+ // Extract axes from structured report format
155
+ if (report.diff.axes) {
156
+ for (const [axis, value] of Object.entries(report.diff.axes)) {
157
+ if (value && String(value).toUpperCase() !== 'SAME') axes[axis] = String(value);
158
+ }
159
+ return { axes, verdict: report.diff.verdict || 'trajectory_unchanged' };
160
+ }
161
+
162
+ // Legacy: raw baseline/kdna comparison
163
+ if (report.without_kdna && report.with_kdna) {
164
+ if (report.without_kdna.classification !== report.with_kdna.classification)
165
+ axes.classification = 'changed';
166
+ return { axes, verdict: Object.keys(axes).length > 0 ? 'trajectory_changed' : 'trajectory_unchanged' };
167
+ }
168
+
169
+ return { axes: {}, verdict: 'trajectory_unchanged' };
170
+ }
171
+
172
+ function createJudgmentDeltaFromReport(domain, input, report, options = {}) {
173
+ const { axes, verdict } = parseCompareReportJson(report);
174
+ const domainScore = scoreDelta(axes);
175
+
176
+ return {
177
+ meta: { domain, input: (input || '').slice(0, 200), model: report.meta?.model || options.model || 'unknown',
178
+ timestamp: new Date().toISOString() },
179
+ classification: { without_kdna: axes.classification || 'generic',
180
+ with_kdna: axes.classification ? 'domain_specific' : 'unchanged', changed: !!axes.classification },
181
+ axes, verdict,
182
+ score: domainScore.score,
183
+ changed_dimensions: domainScore.changed,
184
+ triggered_axioms: options.triggeredAxioms || [],
185
+ avoided_misunderstandings: options.avoidedMisunderstandings || [],
186
+ self_checks_passed: options.selfChecksPassed || null,
187
+ scoring: buildScoring(axes, domainScore, options.selfChecksPassed),
188
+ summary: buildSummary(domain, domainScore, verdict),
189
+ };
190
+ }
191
+
192
+ module.exports = { parseCompareOutput, parseCompareReportJson, scoreDelta,
193
+ createJudgmentDelta, createJudgmentDeltaFromReport, compareDeltas, formatDeltaMarkdown };
@@ -1,181 +1,143 @@
1
1
  /**
2
- * Versioning — Judgment-aware version management.
2
+ * Versioning — Judgment-aware semver with refined bump rules (v0.3.3).
3
3
  *
4
- * KDNA versioning tracks judgment changes, not just text diffs.
5
- * A version bump is based on:
6
- * - PATCH: wording fixes, clarifications (no judgment change)
7
- * - MINOR: new axioms, misunderstandings, self-checks added
8
- * - MAJOR: axioms removed, domain scope changed, access mode changed
9
- *
10
- * Provides:
11
- * - Judgment diff between two project snapshots
12
- * - Changelog generation from audit logs
13
- * - Version bump recommendation
14
- * - Semantic version tracking
4
+ * PATCH: typo, description, Feynman restatement, evidence_refs, examples
5
+ * MINOR: new axiom/misunderstanding/self_check, narrowed applies_when, new does_not_apply_when, new evals
6
+ * MAJOR: removed axiom, changed core meaning, expanded applies_when, removed does_not_apply_when, scope change, access change
15
7
  */
16
8
 
17
9
  function diffProjects(oldProject, newProject) {
18
10
  const oldCards = oldProject.cards || [];
19
11
  const newCards = newProject.cards || [];
20
-
21
12
  const oldById = new Map(oldCards.map(c => [c.id, c]));
22
13
  const newById = new Map(newCards.map(c => [c.id, c]));
23
14
 
24
- const added = [];
25
- const removed = [];
26
- const changed = [];
27
- const unchanged = [];
28
-
29
- for (const [id, newCard] of newById) {
30
- if (!oldById.has(id)) {
31
- added.push({ id, type: newCard.type, one_sentence: newCard.fields?.one_sentence || newCard.fields?.question || '' });
32
- } else {
33
- const oldCard = oldById.get(id);
34
- if (JSON.stringify(oldCard.fields) !== JSON.stringify(newCard.fields)) {
35
- const fieldChanges = diffFields(oldCard.fields || {}, newCard.fields || {});
36
- changed.push({ id, type: newCard.type, changes: fieldChanges });
37
- } else if (oldCard.status !== newCard.status) {
38
- changed.push({ id, type: newCard.type, status_change: { from: oldCard.status, to: newCard.status } });
39
- } else {
40
- unchanged.push(id);
15
+ const added = []; const removed = []; const changed = [];
16
+ for (const [id, nc] of newById) {
17
+ if (!oldById.has(id)) { added.push(cardSummary(nc)); }
18
+ else {
19
+ const oc = oldById.get(id);
20
+ const fieldChanges = diffFields(oc.fields || {}, nc.fields || {});
21
+ if (Object.keys(fieldChanges).length > 0) {
22
+ changed.push(cardSummary(nc, fieldChanges));
23
+ } else if (oc.status !== nc.status) {
24
+ changed.push({ ...cardSummary(nc), status_change: { from: oc.status, to: nc.status } });
41
25
  }
42
26
  }
43
27
  }
44
-
45
- for (const [id, oldCard] of oldById) {
46
- if (!newById.has(id)) {
47
- removed.push({ id, type: oldCard.type, one_sentence: oldCard.fields?.one_sentence || oldCard.fields?.question || '' });
48
- }
28
+ for (const [id, oc] of oldById) {
29
+ if (!newById.has(id)) removed.push(cardSummary(oc));
49
30
  }
50
31
 
51
- return {
52
- added,
53
- removed,
54
- changed,
55
- unchanged: unchanged.length,
56
- summary: {
57
- added_count: added.length,
58
- removed_count: removed.length,
59
- changed_count: changed.length,
60
- unchanged_count: unchanged.length,
61
- },
62
- };
32
+ return { added, removed, changed, unchanged: oldCards.length - removed.length - changed.length,
33
+ summary: { added_count: added.length, removed_count: removed.length, changed_count: changed.length } };
34
+ }
35
+
36
+ function cardSummary(card, changes) {
37
+ return { id: card.id, type: card.type, one_sentence: card.fields?.one_sentence || card.fields?.question || '',
38
+ changes: changes || null };
63
39
  }
64
40
 
65
41
  function diffFields(oldFields, newFields) {
66
42
  const changes = {};
67
43
  for (const key of new Set([...Object.keys(oldFields), ...Object.keys(newFields)])) {
68
- const oldVal = JSON.stringify(oldFields[key] || null);
69
- const newVal = JSON.stringify(newFields[key] || null);
70
- if (oldVal !== newVal) {
71
- changes[key] = { before: oldFields[key] || null, after: newFields[key] || null };
72
- }
44
+ const ov = JSON.stringify(oldFields[key] || null), nv = JSON.stringify(newFields[key] || null);
45
+ if (ov !== nv) changes[key] = { before: oldFields[key] || null, after: newFields[key] || null };
73
46
  }
74
47
  return changes;
75
48
  }
76
49
 
77
50
  function recommendVersionBump(diff) {
78
51
  const { added, removed, changed } = diff;
52
+ const removedAxioms = removed.filter(c => c.type === 'axiom');
53
+ const removedMisunderstandings = removed.filter(c => c.type === 'misunderstanding');
54
+
55
+ // MAJOR checks
56
+ if (removedAxioms.length > 0 || removedMisunderstandings.length > 0) return 'major';
57
+ for (const c of changed) {
58
+ if (!c.changes) continue;
59
+ // Core meaning change on axiom → major
60
+ if (c.type === 'axiom' && ('one_sentence' in c.changes || 'full_statement' in c.changes)) return 'major';
61
+ // Expanded scope → major
62
+ if ('applies_when' in c.changes) {
63
+ const bef = c.changes.applies_when.before || [], aft = c.changes.applies_when.after || [];
64
+ if (aft.length > bef.length) return 'major';
65
+ }
66
+ // Removed boundary → major
67
+ if ('does_not_apply_when' in c.changes) {
68
+ const bef = c.changes.does_not_apply_when.before || [], aft = c.changes.does_not_apply_when.after || [];
69
+ if (aft.length < bef.length) return 'major';
70
+ }
71
+ }
79
72
 
80
- // MAJOR: axioms removed or domain structure changed
81
- const axiomsRemoved = removed.filter(c => c.type === 'axiom').length;
82
- const misunderstandingsRemoved = removed.filter(c => c.type === 'misunderstanding').length;
83
- if (axiomsRemoved > 0 || misunderstandingsRemoved > 0) return 'major';
84
-
85
- // MINOR: new axioms, misunderstandings, or field changes on existing cards
86
- const axiomsAdded = added.filter(c => c.type === 'axiom').length;
87
- const misunderstandingsAdded = added.filter(c => c.type === 'misunderstanding').length;
88
- if (axiomsAdded > 0 || misunderstandingsAdded > 0 || changed.length > 0) return 'minor';
73
+ // MINOR checks
74
+ const addedAxioms = added.filter(c => c.type === 'axiom');
75
+ const addedMisunderstandings = added.filter(c => c.type === 'misunderstanding');
76
+ const addedSelfChecks = added.filter(c => c.type === 'self_check');
77
+ if (addedAxioms.length > 0 || addedMisunderstandings.length > 0 || addedSelfChecks.length > 0) return 'minor';
78
+ for (const c of changed) {
79
+ if (!c.changes) continue;
80
+ // Narrowed scope minor
81
+ if ('does_not_apply_when' in c.changes) {
82
+ const bef = c.changes.does_not_apply_when.before || [], aft = c.changes.does_not_apply_when.after || [];
83
+ if (aft.length > bef.length) return 'minor';
84
+ }
85
+ // Changed why/key_distinction → minor
86
+ if (c.type === 'axiom' && 'why' in c.changes) return 'minor';
87
+ if (c.type === 'misunderstanding' && 'key_distinction' in c.changes) return 'minor';
88
+ }
89
89
 
90
- // PATCH: wording-only changes (status changes, new self-checks, new boundaries)
90
+ // PATCH: wording-only changes
91
91
  if (added.length > 0 || changed.length > 0) return 'patch';
92
-
93
92
  return 'none';
94
93
  }
95
94
 
96
95
  function generateChangelog(diff, oldVersion, newVersion, options = {}) {
97
96
  const lines = [];
97
+ const bump = recommendVersionBump(diff);
98
98
  lines.push(`# ${options.domain || 'domain'} v${newVersion}`);
99
99
  lines.push('');
100
- lines.push(`**Previous:** v${oldVersion}`);
101
- lines.push(`**Bump:** ${recommendVersionBump(diff).toUpperCase()}`);
100
+ lines.push(`**Previous:** v${oldVersion} **Bump:** ${bump.toUpperCase()}`);
102
101
  lines.push('');
103
102
 
104
- if (diff.summary.added_count > 0) {
105
- lines.push('## Added');
106
- lines.push('');
107
- for (const card of diff.added) {
108
- lines.push(`- **${card.type}** \`${card.id}\`: ${card.one_sentence}`);
109
- }
110
- lines.push('');
111
- }
112
-
113
- if (diff.summary.removed_count > 0) {
114
- lines.push('## Removed');
115
- lines.push('');
116
- for (const card of diff.removed) {
117
- lines.push(`- **${card.type}** \`${card.id}\`: ${card.one_sentence}`);
118
- }
119
- lines.push('');
120
- }
121
-
122
- if (diff.summary.changed_count > 0) {
123
- lines.push('## Changed');
124
- lines.push('');
125
- for (const card of diff.changed) {
126
- lines.push(`- **${card.type}** \`${card.id}\``);
127
- if (card.status_change) {
128
- lines.push(` - Status: ${card.status_change.from} → ${card.status_change.to}`);
129
- }
130
- if (card.changes && Object.keys(card.changes).length > 0) {
131
- for (const [field, change] of Object.entries(card.changes)) {
132
- const before = typeof change.before === 'string' ? change.before.slice(0, 80) : JSON.stringify(change.before).slice(0, 80);
133
- const after = typeof change.after === 'string' ? change.after.slice(0, 80) : JSON.stringify(change.after).slice(0, 80);
134
- lines.push(` - ${field}: "${before}" → "${after}"`);
135
- }
103
+ for (const [label, items] of [['Added', diff.added], ['Removed', diff.removed], ['Changed', diff.changed]]) {
104
+ if (items.length === 0) continue;
105
+ lines.push(`## ${label}`); lines.push('');
106
+ for (const c of items) {
107
+ lines.push(`- **${c.type}** \`${c.id}\`: ${c.one_sentence}`);
108
+ if (c.status_change) lines.push(` - Status: ${c.status_change.from} → ${c.status_change.to}`);
109
+ if (c.changes) for (const [f, v] of Object.entries(c.changes)) {
110
+ lines.push(` - ${f}: "${String(v.before || '').slice(0, 60)}" → "${String(v.after || '').slice(0, 60)}"`);
136
111
  }
137
112
  }
138
113
  lines.push('');
139
114
  }
140
115
 
141
- if (diff.summary.added_count === 0 && diff.summary.removed_count === 0 && diff.summary.changed_count === 0) {
142
- lines.push('No judgment changes detected.');
143
- lines.push('');
116
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
117
+ lines.push('No judgment changes detected.\n');
144
118
  }
145
119
 
146
120
  return lines.join('\n');
147
121
  }
148
122
 
149
123
  function bumpVersion(currentVersion, bumpType) {
150
- const parts = currentVersion.split('.').map(Number);
151
- switch (bumpType) {
152
- case 'major': return `${parts[0] + 1}.0.0`;
153
- case 'minor': return `${parts[0]}.${parts[1] + 1}.0`;
154
- case 'patch': return `${parts[0]}.${parts[1]}.${parts[2] + 1}`;
155
- default: return currentVersion;
156
- }
124
+ const [maj, min, pat] = currentVersion.split('.').map(Number);
125
+ if (bumpType === 'major') return `${maj + 1}.0.0`;
126
+ if (bumpType === 'minor') return `${maj}.${min + 1}.0`;
127
+ if (bumpType === 'patch') return `${maj}.${min}.${pat + 1}`;
128
+ return currentVersion;
157
129
  }
158
130
 
159
131
  function markBreakingChange(diff) {
160
- const axiomsRemoved = diff.removed.filter(c => c.type === 'axiom').length;
161
- const scopeChanges = diff.changed.filter(c =>
162
- c.changes && ('applies_when' in c.changes || 'does_not_apply_when' in c.changes)
163
- ).length;
132
+ const removedAxioms = diff.removed.filter(c => c.type === 'axiom');
133
+ const scopeWidening = diff.changed.filter(c => c.changes && 'applies_when' in c.changes &&
134
+ (c.changes.applies_when.after || []).length > (c.changes.applies_when.before || []).length);
164
135
  return {
165
- breaking: axiomsRemoved > 0,
166
- reason: axiomsRemoved > 0
167
- ? `${axiomsRemoved} axiom(s) removed breaking change`
168
- : scopeChanges > 0
169
- ? `${scopeChanges} scope change(s) — may affect existing agent behavior`
170
- : null,
136
+ breaking: removedAxioms.length > 0,
137
+ reason: removedAxioms.length > 0 ? `${removedAxioms.length} axiom(s) removed — breaking change` :
138
+ scopeWidening.length > 0 ? `${scopeWidening.length} scope widening(s) — may affect existing behavior` : null,
171
139
  recommended_bump: recommendVersionBump(diff),
172
140
  };
173
141
  }
174
142
 
175
- module.exports = {
176
- diffProjects,
177
- recommendVersionBump,
178
- generateChangelog,
179
- bumpVersion,
180
- markBreakingChange,
181
- };
143
+ module.exports = { diffProjects, recommendVersionBump, generateChangelog, bumpVersion, markBreakingChange };