@aikdna/studio-core 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/studio-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "KDNA Studio Core — pure logic library for authoring, validating, and compiling KDNA domain judgment packages.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -3,32 +3,22 @@
3
3
  *
4
4
  * Only locked cards enter compilation output.
5
5
  * Draft/Revised cards are silently excluded.
6
+ * Supports full 6-file output: Core / Patterns / Scenarios / Cases / Reasoning / Evolution.
6
7
  */
8
+ const provenance = require('../provenance');
7
9
 
8
10
  function compileCore(cards) {
9
- const axioms = cards.filter(c => c.type === 'axiom' && c.locked).map(c => ({
10
- id: c.id,
11
- ...c.fields,
12
- }));
13
- const ontology = cards.filter(c => c.type === 'ontology' && c.locked).map(c => ({
14
- id: c.id,
15
- ...c.fields,
16
- }));
11
+ const axioms = cards.filter(c => c.type === 'axiom' && c.locked).map(c => ({ id: c.id, ...c.fields }));
12
+ const ontology = cards.filter(c => c.type === 'ontology' && c.locked).map(c => ({ id: c.id, ...c.fields }));
17
13
  const frameworks = [];
18
14
  const stances = [];
19
15
  const boundaries = cards.filter(c => c.type === 'boundary' && c.locked).map(c => ({
20
16
  id: c.id,
21
- scope: c.fields.scope,
22
- out_of_scope: c.fields.out_of_scope,
23
- acceptable_exceptions: c.fields.acceptable_exceptions || [],
24
- }));
25
-
26
- const risks = cards.filter(c => c.type === 'risk' && c.locked).map(c => ({
27
- id: c.id,
28
- failure_mode: c.fields.failure_mode,
29
- likelihood: c.fields.likelihood,
30
- mitigation: c.fields.mitigation,
17
+ scope: c.fields?.scope || '',
18
+ out_of_scope: c.fields?.out_of_scope || '',
19
+ acceptable_exceptions: c.fields?.acceptable_exceptions || [],
31
20
  }));
21
+ const risks = cards.filter(c => c.type === 'risk' && c.locked).map(c => ({ id: c.id, ...c.fields }));
32
22
 
33
23
  return { axioms, ontology, frameworks, stances, boundaries, risks };
34
24
  }
@@ -36,52 +26,98 @@ function compileCore(cards) {
36
26
  function compilePatterns(cards) {
37
27
  const misunderstandings = cards.filter(c => c.type === 'misunderstanding' && c.locked).map(c => ({
38
28
  id: c.id,
39
- wrong: c.fields.wrong,
40
- correct: c.fields.correct,
41
- key_distinction: c.fields.key_distinction,
42
- failure_risk: c.fields.failure_risk,
43
- }));
44
- const selfChecks = cards.filter(c => c.type === 'self_check' && c.locked).map(c => c.fields.question);
45
- const bannedTerms = [];
46
- const aesthetics = cards.filter(c => c.type === 'aesthetic' && c.locked).map(c => ({
47
- id: c.id,
48
- preference: c.fields.preference,
49
- rationale: c.fields.rationale,
29
+ wrong: c.fields?.wrong || '',
30
+ correct: c.fields?.correct || '',
31
+ key_distinction: c.fields?.key_distinction || '',
32
+ failure_risk: c.fields?.failure_risk || null,
33
+ applies_when: c.fields?.applies_when || [],
34
+ does_not_apply_when: c.fields?.does_not_apply_when || [],
50
35
  }));
36
+ const selfCheckQuestions = cards.filter(c => c.type === 'self_check' && c.locked).map(c => c.fields?.question || '');
37
+ const aesthetics = cards.filter(c => c.type === 'aesthetic' && c.locked).map(c => ({ id: c.id, ...c.fields }));
51
38
 
52
39
  const terminology = {
53
40
  standard_terms: [],
54
- banned_terms: bannedTerms,
41
+ banned_terms: [],
55
42
  };
56
43
 
57
- return { terminology, misunderstandings, self_check: selfChecks, aesthetics };
44
+ return { terminology, misunderstandings, self_check: selfCheckQuestions, aesthetics };
58
45
  }
59
46
 
60
47
  function compileScenarios(cards) {
61
- return cards.filter(c => c.type === 'scenario' && c.locked).map(c => ({
62
- id: c.id,
63
- ...c.fields,
64
- }));
48
+ const locked = cards.filter(c => c.type === 'scenario' && c.locked);
49
+ if (locked.length === 0) return [];
50
+ return locked.map(c => ({ id: c.id, ...c.fields }));
65
51
  }
66
52
 
67
53
  function compileCases(cards) {
68
- return cards.filter(c => c.type === 'case' && c.locked).map(c => ({
69
- id: c.id,
70
- ...c.fields,
54
+ const locked = cards.filter(c => c.type === 'case' && c.locked);
55
+ if (locked.length === 0) return [];
56
+ return locked.map(c => ({ id: c.id, ...c.fields }));
57
+ }
58
+
59
+ function compileReasoning(cards) {
60
+ // Reasoning chains from axiom implications
61
+ const lockedAxioms = cards.filter(c => c.type === 'axiom' && c.locked);
62
+ if (lockedAxioms.length === 0) return [];
63
+ return lockedAxioms.map(ax => ({
64
+ id: `chain_${ax.id}`,
65
+ from: ax.fields?.one_sentence || '',
66
+ logic: [ax.fields?.full_statement || ''],
67
+ so_what: ax.fields?.why || 'Agent judgment changes when this axiom is loaded.',
71
68
  }));
72
69
  }
73
70
 
71
+ function compileEvolution(cards) {
72
+ const lockedCards = cards.filter(c => c.locked);
73
+ if (lockedCards.length === 0) return { stages: [], capability_layers: [], measurements: [] };
74
+
75
+ // Build evolution from audit logs
76
+ const stages = [];
77
+ const seenAxioms = new Set();
78
+ for (const card of lockedCards) {
79
+ if (seenAxioms.has(card.id)) continue;
80
+ seenAxioms.add(card.id);
81
+ for (const entry of (card.audit_log || [])) {
82
+ if (entry.event === 'locked' || entry.event === 'published') {
83
+ stages.push({
84
+ card_id: card.id,
85
+ event: entry.event,
86
+ at: entry.at,
87
+ by: entry.by,
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ return {
94
+ stages: stages.sort((a, b) => a.at.localeCompare(b.at)),
95
+ capability_layers: [
96
+ { layer: 1, name: 'Foundation', description: 'Core axioms and patterns established.' },
97
+ ],
98
+ measurements: [
99
+ { metric: 'locked_axioms', value: lockedCards.filter(c => c.type === 'axiom').length },
100
+ { metric: 'locked_misunderstandings', value: lockedCards.filter(c => c.type === 'misunderstanding').length },
101
+ { metric: 'self_checks', value: lockedCards.filter(c => c.type === 'self_check').length },
102
+ ],
103
+ };
104
+ }
105
+
74
106
  function compileManifest(project) {
75
- const lockedCount = project.cards.filter(c => c.locked).length;
107
+ const lockedCount = (project.cards || []).filter(c => c.locked).length;
108
+ const cards = project.cards || [];
109
+ const hasScenarios = cards.some(c => c.type === 'scenario' && c.locked);
110
+ const hasCases = cards.some(c => c.type === 'case' && c.locked);
111
+ const fileCount = 2 + (hasScenarios ? 1 : 0) + (hasCases ? 1 : 0) + 2; // Core+Patterns+Reasoning+Evolution (+ Scenarios + Cases)
76
112
  return {
77
113
  kdna_spec: '1.0-rc',
78
114
  name: project.name,
79
- version: project.release?.version || '0.1.0',
115
+ version: (project.release && project.release.version) || '0.1.0',
80
116
  status: 'experimental',
81
- access: project.release?.access || 'open',
82
- author: project.author,
83
- description: project.release?.description || project.name,
84
- file_count: 2, // Core + Patterns minimum
117
+ access: (project.release && project.release.access) || 'open',
118
+ author: project.author || { name: '', id: '' },
119
+ description: project.name,
120
+ file_count: 2,
85
121
  created: project.created,
86
122
  updated: project.updated,
87
123
  };
@@ -93,26 +129,129 @@ function compileDomain(project) {
93
129
  const patterns = compilePatterns(cards);
94
130
  const scenarios = compileScenarios(cards);
95
131
  const cases = compileCases(cards);
132
+ const reasoning = compileReasoning(cards);
133
+ const evolution = compileEvolution(cards);
96
134
  const manifest = compileManifest(project);
97
135
 
98
136
  const files = {};
99
137
  files['KDNA_Core.json'] = JSON.stringify(core, null, 2);
100
138
  files['KDNA_Patterns.json'] = JSON.stringify(patterns, null, 2);
101
- if (scenarios.length > 0) files['KDNA_Scenarios.json'] = JSON.stringify(scenarios, null, 2);
102
- if (cases.length > 0) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
139
+ if (scenarios.length > 0) files['KDNA_Scenarios.json'] = JSON.stringify({ scenes: scenarios }, null, 2);
140
+ if (cases.length > 0) files['KDNA_Cases.json'] = JSON.stringify({ cases }, null, 2);
141
+ if (reasoning.length > 0) files['KDNA_Reasoning.json'] = JSON.stringify({ chains: reasoning }, null, 2);
142
+ if (evolution.stages && evolution.stages.length > 0) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
103
143
  files['kdna.json'] = JSON.stringify(manifest, null, 2);
104
144
 
105
- const excludedCount = (project.cards || []).filter(c => !c.locked && !['deprecated'].includes(c.status)).length;
145
+ const excludedCount = cards.filter(c => !c.locked && !['deprecated'].includes(c.status)).length;
106
146
 
107
147
  return {
108
148
  files,
109
149
  stats: {
110
- total_cards: (project.cards || []).length,
111
- locked_cards: (project.cards || []).filter(c => c.locked).length,
150
+ total_cards: cards.length,
151
+ locked_cards: cards.filter(c => c.locked).length,
112
152
  excluded_cards: excludedCount,
113
- deprecated_cards: (project.cards || []).filter(c => c.status === 'deprecated').length,
153
+ deprecated_cards: cards.filter(c => c.status === 'deprecated').length,
154
+ files_output: Object.keys(files).length,
114
155
  },
115
156
  };
116
157
  }
117
158
 
118
- module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileManifest };
159
+ function generateReadme(project, options = {}) {
160
+ const cards = project.cards || [];
161
+ const locked = cards.filter(c => c.locked);
162
+ const lockedAxioms = locked.filter(c => c.type === 'axiom');
163
+ const lockedMisunderstandings = locked.filter(c => c.type === 'misunderstanding');
164
+ const lockedSelfChecks = locked.filter(c => c.type === 'self_check');
165
+ const lockedBoundaries = locked.filter(c => c.type === 'boundary');
166
+ const tests = project.tests || [];
167
+
168
+ const lines = [];
169
+ lines.push(`# ${project.name}`);
170
+ lines.push('');
171
+ if (options.description) {
172
+ lines.push(options.description);
173
+ lines.push('');
174
+ }
175
+
176
+ // Four Questions
177
+ lines.push('## Where it comes from');
178
+ lines.push('');
179
+ lines.push(options.origin || `Domain expertise encoded into ${locked.length} judgment cards through structured interview and human lock.`);
180
+ lines.push('');
181
+
182
+ lines.push('## Where it applies');
183
+ lines.push('');
184
+ const appliesWhen = [...new Set(lockedAxioms.flatMap(ax => ax.fields?.applies_when || []))];
185
+ if (appliesWhen.length > 0) {
186
+ appliesWhen.forEach(w => lines.push(`- ${w}`));
187
+ } else {
188
+ lines.push('- As declared in each axiom\'s applies_when field.');
189
+ }
190
+ lines.push('');
191
+
192
+ lines.push('## How it is verified');
193
+ lines.push('');
194
+ lines.push(`- ${tests.length} eval cases (${tests.filter(t => t.result).length} rated)`);
195
+ lines.push(`- ${lockedAxioms.length} locked axioms with applies_when / does_not_apply_when / failure_risk`);
196
+ lines.push(`- ${lockedSelfChecks.length} self-check questions`);
197
+ lines.push(`- ${lockedMisunderstandings.length} misunderstanding patterns`);
198
+ lines.push('');
199
+
200
+ lines.push('## When it does NOT apply');
201
+ lines.push('');
202
+ const notApply = [...new Set(lockedAxioms.flatMap(ax => ax.fields?.does_not_apply_when || []))];
203
+ if (notApply.length > 0) {
204
+ notApply.forEach(w => lines.push(`- ${w}`));
205
+ }
206
+ const outOfScope = lockedBoundaries.flatMap(b => [b.fields?.out_of_scope || '']).filter(Boolean);
207
+ for (const oos of outOfScope) {
208
+ if (!notApply.includes(oos)) lines.push(`- ${oos}`);
209
+ }
210
+ lines.push('');
211
+
212
+ // Top Axioms
213
+ if (lockedAxioms.length > 0) {
214
+ lines.push('## Top Axioms');
215
+ lines.push('');
216
+ lockedAxioms.forEach(ax => {
217
+ lines.push(`- **${ax.fields?.one_sentence || ax.id}**`);
218
+ if (ax.fields?.failure_risk) lines.push(` - Failure risk: ${ax.fields.failure_risk}`);
219
+ });
220
+ lines.push('');
221
+ }
222
+
223
+ // Top Misunderstandings
224
+ if (lockedMisunderstandings.length > 0) {
225
+ lines.push('## Top Misunderstandings');
226
+ lines.push('');
227
+ lockedMisunderstandings.forEach(ms => {
228
+ lines.push(`- WRONG: ${ms.fields?.wrong}`);
229
+ lines.push(` CORRECT: ${ms.fields?.correct}`);
230
+ });
231
+ lines.push('');
232
+ }
233
+
234
+ // Self-checks
235
+ if (lockedSelfChecks.length > 0) {
236
+ lines.push('## Eval Score');
237
+ lines.push('');
238
+ lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 5 ? 'tested' : 'untested'}`);
239
+ lines.push(`- eval cases: ${tests.length}`);
240
+ lines.push('');
241
+ }
242
+
243
+ // Files
244
+ lines.push('## Files');
245
+ lines.push('');
246
+ const fileCount = 2
247
+ + (cards.filter(c => c.type === 'scenario' && c.locked).length > 0 ? 1 : 0)
248
+ + (cards.filter(c => c.type === 'case' && c.locked).length > 0 ? 1 : 0)
249
+ + (lockedAxioms.length > 0 ? 1 : 0)
250
+ + (cards.filter(c => c.status === 'locked' || c.status === 'tested').length > 0 ? 1 : 0);
251
+ lines.push(`${fileCount} KDNA JSON files + evals/ + demo/`);
252
+ lines.push('');
253
+
254
+ return lines.join('\n');
255
+ }
256
+
257
+ module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileReasoning, compileEvolution, compileManifest, generateReadme };
package/src/index.js CHANGED
@@ -28,6 +28,8 @@ const testlab = require('./testlab');
28
28
  const versioning = require('./versioning');
29
29
  const feynman = require('./cards/feynman');
30
30
  const contradiction = require('./quality/contradiction');
31
+ const validateCards = require('./quality/validate-cards');
32
+ const delta = require('./testlab/delta');
31
33
 
32
34
  module.exports = {
33
35
  cards,
@@ -41,4 +43,6 @@ module.exports = {
41
43
  versioning,
42
44
  feynman,
43
45
  contradiction,
46
+ validateCards,
47
+ delta,
44
48
  };
@@ -1,65 +1,175 @@
1
1
  /**
2
- * Quality gates and readiness scoring.
2
+ * Enhanced Quality Gates 4-grade readiness scoring with detailed rules.
3
3
  *
4
- * Four quality grades:
5
- * draft_grade structure exists, at least 3 human-reviewed cards
6
- * human_controlled all core axioms locked, each with applies_when/does_not_apply_when/failure_risk
7
- * tested_grade at least 5 eval cases, at least 3 comparison tests
8
- * publishable_grade at least 10 evals, README complete, kdna verify --judgment passes
4
+ * Grades:
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
9
9
  */
10
+ const contradiction = require('./contradiction');
11
+
10
12
  function computeReadiness(project) {
11
13
  const cards = project.cards || [];
12
- const locked = cards.filter(c => c.locked);
13
14
  const tests = project.tests || [];
15
+ const locked = cards.filter(c => c.locked);
16
+ const lockedAxioms = locked.filter(c => c.type === 'axiom');
17
+ const lockedSelfChecks = locked.filter(c => c.type === 'self_check');
18
+ const ratedTests = tests.filter(t => t.result);
14
19
 
15
20
  const blocking = [];
16
21
  const warnings = [];
17
22
 
18
- // Check minimum locked axioms
19
- const lockedAxioms = locked.filter(c => c.type === 'axiom');
20
- if (lockedAxioms.length < 1) blocking.push('At least 1 locked axiom required');
21
- if (lockedAxioms.length < 3) warnings.push('Recommend at least 3 locked axioms');
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);
31
+ }
22
32
 
23
- // Check boundary completeness
33
+ // ── Axiom Checks ──────────────────────────────────────────────
24
34
  for (const ax of lockedAxioms) {
25
- if (!ax.fields?.does_not_apply_when?.length) {
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) {
26
48
  blocking.push(`${ax.id}: missing does_not_apply_when`);
27
49
  }
28
50
  if (!ax.fields?.failure_risk) {
29
- warnings.push(`${ax.id}: missing failure_risk`);
51
+ blocking.push(`${ax.id}: missing failure_risk`);
30
52
  }
31
53
  if (!ax.human_lock) {
32
- blocking.push(`${ax.id}: missing human_lock`);
54
+ blocking.push(`${ax.id}: not locked — must be locked before compile`);
33
55
  }
34
56
  if (!ax.feynman_restatement) {
35
57
  warnings.push(`${ax.id}: missing Feynman restatement`);
36
58
  }
37
59
  }
38
60
 
39
- // Check self-checks
40
- const lockedSelfChecks = locked.filter(c => c.type === 'self_check');
61
+ // ── Self-check Checks ──────────────────────────────────────────
41
62
  for (const sc of lockedSelfChecks) {
42
- if (sc.fields?.question && !sc.fields.question.trim().endsWith('?')) {
43
- blocking.push(`${sc.id}: self_check question must end with ?`);
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
+ }
73
+ }
74
+
75
+ // ── Misunderstanding Checks ────────────────────────────────────
76
+ const lockedMisunderstandings = locked.filter(c => c.type === 'misunderstanding');
77
+ 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`);
44
83
  }
84
+ if (!ms.fields?.correct || ms.fields.correct.length < 10) {
85
+ warnings.push(`${ms.id}: correct belief very short`);
86
+ }
87
+ }
88
+
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
+ }
95
+ }
96
+
97
+ // ── 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');
45
107
  }
108
+ if (ratedTests.length < 3 && ratedTests.length > 0) {
109
+ warnings.push(`Only ${ratedTests.length} rated tests — recommend at least 3 for confidence`);
110
+ }
111
+
112
+ // ── Determine Grade ────────────────────────────────────────────
113
+ 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);
46
123
 
47
- // Determine grade
48
124
  let grade = 'draft_grade';
49
- const axiomsComplete = lockedAxioms.every(ax =>
50
- ax.fields?.applies_when?.length && ax.fields?.does_not_apply_when?.length && ax.human_lock
51
- );
52
- if (axiomsComplete && locked.length >= 3) grade = 'human_controlled';
53
- if (axiomsComplete && tests.length >= 5) grade = 'tested_grade';
54
- if (axiomsComplete && tests.length >= 10 && lockedSelfChecks.length >= 3) grade = 'publishable_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) {
136
+ grade = 'publishable_grade';
137
+ }
138
+
139
+ return buildResult(grade, blocking, warnings, project);
140
+ }
141
+
142
+ function buildResult(grade, blocking, warnings, project) {
143
+ const lockedCount = (project.cards || []).filter(c => c.locked).length;
144
+ const ratedTests = (project.tests || []).filter(t => t.result).length;
55
145
 
56
146
  return {
57
147
  grade,
58
148
  publishable: grade === 'publishable_grade' && blocking.length === 0,
59
149
  blocking,
60
150
  warnings,
61
- score: Math.max(0, 100 - blocking.length * 15 - warnings.length * 5),
151
+ score: Math.max(0, 100 - blocking.length * 15 - warnings.length * 3),
152
+ stats: {
153
+ total_cards: (project.cards || []).length,
154
+ locked_cards: lockedCount,
155
+ locked_axioms: (project.cards || []).filter(c => c.type === 'axiom' && c.locked).length,
156
+ locked_self_checks: (project.cards || []).filter(c => c.type === 'self_check' && c.locked).length,
157
+ total_tests: (project.tests || []).length,
158
+ rated_tests: ratedTests,
159
+ },
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.',
62
167
  };
63
168
  }
64
169
 
65
- module.exports = { computeReadiness };
170
+ function getBlockingIssues(project) {
171
+ const result = computeReadiness(project);
172
+ return result.blocking;
173
+ }
174
+
175
+ module.exports = { computeReadiness, getBlockingIssues };