@tenphi/akno-core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/config/default.jsonc +7 -5
  2. package/dist/bench/answer.js +5 -2
  3. package/dist/bench/answer.js.map +1 -1
  4. package/dist/config/schema.d.ts +8 -0
  5. package/dist/config/schema.d.ts.map +1 -1
  6. package/dist/config/schema.js +2 -0
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/index/derive.d.ts.map +1 -1
  9. package/dist/index/derive.js +17 -0
  10. package/dist/index/derive.js.map +1 -1
  11. package/dist/index/graph.d.ts +1 -0
  12. package/dist/index/graph.d.ts.map +1 -1
  13. package/dist/index/graph.js +50 -0
  14. package/dist/index/graph.js.map +1 -1
  15. package/dist/index/indexer.d.ts +3 -0
  16. package/dist/index/indexer.d.ts.map +1 -1
  17. package/dist/index/indexer.js +34 -3
  18. package/dist/index/indexer.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/kb/page.d.ts +4 -1
  23. package/dist/kb/page.d.ts.map +1 -1
  24. package/dist/kb/page.js +5 -0
  25. package/dist/kb/page.js.map +1 -1
  26. package/dist/maintenance/dream.d.ts +1 -1
  27. package/dist/maintenance/dream.d.ts.map +1 -1
  28. package/dist/maintenance/dream.js +547 -198
  29. package/dist/maintenance/dream.js.map +1 -1
  30. package/dist/maintenance/observation-migration.d.ts +17 -0
  31. package/dist/maintenance/observation-migration.d.ts.map +1 -0
  32. package/dist/maintenance/observation-migration.js +217 -0
  33. package/dist/maintenance/observation-migration.js.map +1 -0
  34. package/dist/maintenance/observe.d.ts +20 -3
  35. package/dist/maintenance/observe.d.ts.map +1 -1
  36. package/dist/maintenance/observe.js +95 -24
  37. package/dist/maintenance/observe.js.map +1 -1
  38. package/dist/maintenance/path-policy.d.ts.map +1 -1
  39. package/dist/maintenance/path-policy.js +2 -2
  40. package/dist/maintenance/path-policy.js.map +1 -1
  41. package/dist/maintenance/plans.d.ts +28 -1
  42. package/dist/maintenance/plans.d.ts.map +1 -1
  43. package/dist/maintenance/plans.js +360 -17
  44. package/dist/maintenance/plans.js.map +1 -1
  45. package/dist/observations/marker.d.ts +27 -0
  46. package/dist/observations/marker.d.ts.map +1 -0
  47. package/dist/observations/marker.js +238 -0
  48. package/dist/observations/marker.js.map +1 -0
  49. package/dist/observations/projection.d.ts +23 -0
  50. package/dist/observations/projection.d.ts.map +1 -0
  51. package/dist/observations/projection.js +349 -0
  52. package/dist/observations/projection.js.map +1 -0
  53. package/dist/open.d.ts +3 -0
  54. package/dist/open.d.ts.map +1 -1
  55. package/dist/open.js +2 -0
  56. package/dist/open.js.map +1 -1
  57. package/dist/ops/answer.d.ts.map +1 -1
  58. package/dist/ops/answer.js +74 -12
  59. package/dist/ops/answer.js.map +1 -1
  60. package/dist/ops/context.js +3 -1
  61. package/dist/ops/context.js.map +1 -1
  62. package/dist/ops/graph.d.ts.map +1 -1
  63. package/dist/ops/graph.js +22 -2
  64. package/dist/ops/graph.js.map +1 -1
  65. package/dist/ops/read.d.ts.map +1 -1
  66. package/dist/ops/read.js +9 -3
  67. package/dist/ops/read.js.map +1 -1
  68. package/dist/recall/assemble.d.ts +1 -0
  69. package/dist/recall/assemble.d.ts.map +1 -1
  70. package/dist/recall/assemble.js +18 -7
  71. package/dist/recall/assemble.js.map +1 -1
  72. package/dist/store/db.d.ts.map +1 -1
  73. package/dist/store/db.js +4 -1
  74. package/dist/store/db.js.map +1 -1
  75. package/dist/store/migrations.d.ts +2 -1
  76. package/dist/store/migrations.d.ts.map +1 -1
  77. package/dist/store/migrations.js +50 -1
  78. package/dist/store/migrations.js.map +1 -1
  79. package/package.json +2 -2
@@ -24,6 +24,8 @@ import { verifyDreamRun } from "./run-verification.js";
24
24
  import { Indexer } from "../index/indexer.js";
25
25
  import { planManagedItems } from "./managed-items.js";
26
26
  import { planRuleDrifts, ruleDriftPaths } from "./rule-drift.js";
27
+ import { independentProofGroups, insertObservationBlock, observationBlock, observationId, observationMarkerIndexes, renderObservationMarker, replaceObservationBlock, } from "../observations/marker.js";
28
+ import { liveObservationProofGroups, markerFromProjection, proofGroupsForFact, } from "../observations/projection.js";
27
29
  import { assertProfileAutomaticApplyAvailable, configWithMaintenanceRecovery, maintenanceRecoveryStatus, recordMaintenanceRecovery, } from "./recovery.js";
28
30
  /**
29
31
  * Order matters twice.
@@ -1183,36 +1185,49 @@ async function previewObservePhase(ctx, report) {
1183
1185
  function asPreviewObservation(entry) {
1184
1186
  if (entry.action === 'created')
1185
1187
  return { ...entry, action: 'would-create' };
1188
+ if (entry.action === 'reinforced')
1189
+ return { ...entry, action: 'would-reinforce' };
1186
1190
  if (entry.action === 'refined')
1187
1191
  return { ...entry, action: 'would-refine' };
1192
+ if (entry.action === 'weakened')
1193
+ return { ...entry, action: 'would-weaken' };
1194
+ if (entry.action === 'retracted')
1195
+ return { ...entry, action: 'would-retract' };
1196
+ if (entry.action === 'split')
1197
+ return { ...entry, action: 'would-split' };
1188
1198
  return entry;
1189
1199
  }
1190
1200
  async function collectPreparedObservations(ctx, report) {
1191
1201
  const groups = subjectGroups(ctx, ctx.config.maintenance.observe.maxSubjects, report.conflicts);
1202
+ const prepared = await staleObservationOutcomes(ctx);
1203
+ for (const entry of prepared) {
1204
+ if (entry.rejectionReason)
1205
+ report.rejected.push({ pattern: entry.written.pattern, reason: entry.rejectionReason });
1206
+ }
1192
1207
  if (groups.length === 0)
1193
- return [];
1194
- const prepared = [];
1208
+ return prepared;
1195
1209
  // Gathered once for the whole phase, not per group: fifteen groups reading the same two things
1196
1210
  // fifteen times is the same answer at fifteen times the cost.
1197
1211
  const knownFacts = liveFactClaims(ctx, report.conflicts);
1198
- const observationsBySlug = await allObservations(ctx);
1212
+ const observationsBySlug = indexedObservations(ctx);
1199
1213
  for (const group of groups) {
1200
1214
  const result = await runObserveMission({
1201
- // The folder travels with the subject so the model knows what kind of thing it is
1202
- // looking at: "price, in shopping" is a question; "price" on its own is a word.
1203
- subject: group.folder === '.' ? group.subject : `${group.subject}, in ${group.folder}`,
1204
- facts: group.facts,
1215
+ // The display label comes from the canonical entity page. Group membership itself is the
1216
+ // exact resolved entity id below, never a folder or a model-generated subject string.
1217
+ subject: group.subject,
1218
+ facts: group.facts.map((fact) => ({ id: fact.id, claim: fact.claim, slug: fact.slug })),
1205
1219
  model: ctx.models.derive,
1206
1220
  mission: ctx.config.maintenance.observe.mission,
1207
1221
  minEvidence: ctx.config.maintenance.observe.minEvidence,
1208
1222
  // What last night already concluded about this subject. The facts rarely change between one
1209
1223
  // night and the next, so without this the same insight comes back reworded every night and
1210
1224
  // the page accumulates paraphrases of one sentence.
1211
- existing: observationsBySlug.get(observationSlug(ctx, group.slug)) ?? [],
1225
+ existing: observationsBySlug.get(group.targetSlug) ?? [],
1226
+ existingRecords: indexedObservationRecords(ctx, group.subjectEntity, group.targetSlug),
1212
1227
  // Everything already observed elsewhere, plus anything written earlier in this same run —
1213
1228
  // two groups can reach one conclusion from overlapping facts, and each would otherwise write
1214
1229
  // it to its own page where neither looks like a duplicate.
1215
- otherObservations: [...observationsBySlug].flatMap(([slug, lines]) => slug === observationSlug(ctx, group.slug) ? [] : lines),
1230
+ otherObservations: [...observationsBySlug].flatMap(([slug, lines]) => slug === group.targetSlug ? [] : lines),
1216
1231
  knownFacts,
1217
1232
  });
1218
1233
  if (result.error) {
@@ -1221,227 +1236,473 @@ async function collectPreparedObservations(ctx, report) {
1221
1236
  }
1222
1237
  report.rejected.push(...result.rejected);
1223
1238
  for (const observation of result.observations) {
1224
- const outcome = await prepareObservation(ctx, { title: group.subject, slug: group.slug }, observation);
1239
+ const outcome = await prepareCoLocatedObservation(ctx, group, observation);
1225
1240
  prepared.push(outcome);
1226
1241
  if (outcome.rejectionReason) {
1227
1242
  report.rejected.push({ pattern: observation.pattern, reason: outcome.rejectionReason });
1228
1243
  }
1229
1244
  // So a later group in this same run sees it. Without this, cross-page duplicates are caught
1230
1245
  // only from the second night onwards — the night they are created, they both go through.
1231
- const slug = observationSlug(ctx, group.slug);
1232
- observationsBySlug.set(slug, [...(observationsBySlug.get(slug) ?? []), observation.pattern]);
1246
+ observationsBySlug.set(group.targetSlug, [
1247
+ ...(observationsBySlug.get(group.targetSlug) ?? []),
1248
+ observation.pattern,
1249
+ ]);
1233
1250
  }
1234
1251
  }
1235
1252
  return prepared;
1236
1253
  }
1237
- /**
1238
- * Live facts from knowledge, non-observation pages, grouped by **folder and subject**.
1239
- *
1240
- * Not by subject alone, which is what the first version did and what a real knowledge base
1241
- * immediately exposed. A small deriver writes the *attribute* into `subject` — "price",
1242
- * "address", "location", "opening hours" — so grouping on it joined a bag with a drum kit
1243
- * ("Price is $79 during sales"), and a Roman church with a person's page ("Address is central
1244
- * and near a major landmark"). Given unrelated facts under one heading, the model does what it
1245
- * is asked and finds a pattern across them; the input was the bug.
1246
- *
1247
- * A folder is the coarsest thing in a knowledge base that means "these are about the same kind
1248
- * of thing", and it is the user's own division rather than one Akno invented. Two pages in
1249
- * `shopping/` sharing a subject are at least comparable. Ordered by how recently the pages were
1250
- * touched, so a capped run looks at what is moving.
1251
- */
1254
+ async function staleObservationOutcomes(ctx) {
1255
+ const rows = ctx.store.db
1256
+ .prepare(`SELECT oe.id, oe.source_slug, oe.payload, oe.payload_hash, oe.subject_entity,
1257
+ oe.proof_count, oe.issue, p.rel_path, p.role, p.observe_management
1258
+ FROM observation_entries oe JOIN pages p ON p.id = oe.source_page
1259
+ WHERE oe.eligible = 0 AND oe.disposition = 'active'
1260
+ ORDER BY oe.source_slug, oe.marker_line`)
1261
+ .all();
1262
+ const prepared = [];
1263
+ for (const row of rows) {
1264
+ const marker = markerFromProjection(ctx.store, row.id);
1265
+ const survivingProofs = marker ? liveObservationProofGroups(ctx.store, marker) : new Set();
1266
+ const outcome = survivingProofs.size > 0 ? 'weaken' : 'retract';
1267
+ const action = outcome === 'weaken' ? 'weakened' : 'retracted';
1268
+ const pattern = row.payload
1269
+ .replace(/^- \*\*Observation:\*\*\s*/, '')
1270
+ .replace(/\s+Evidence:\s+(?:\[\[[^\]]+\]\]\s*)+$/, '')
1271
+ .trim();
1272
+ const written = { slug: row.source_slug, pattern, evidence: [], action };
1273
+ if (!marker || row.role !== 'knowledge' || row.observe_management !== 'integrate' || !row.issue) {
1274
+ prepared.push({
1275
+ written: { ...written, action: outcome === 'weaken' ? 'would-weaken' : 'would-retract' },
1276
+ draft: null,
1277
+ rejectionReason: `held ${outcome}: ${row.issue ?? 'the target no longer admits observation integration'}`,
1278
+ });
1279
+ continue;
1280
+ }
1281
+ const absPath = path.join(ctx.config.aknoPath, row.rel_path);
1282
+ const before = await fsp.readFile(absPath, 'utf8').catch(() => null);
1283
+ if (before === null) {
1284
+ prepared.push({
1285
+ written: { ...written, action: outcome === 'weaken' ? 'would-weaken' : 'would-retract' },
1286
+ draft: null,
1287
+ rejectionReason: `held ${outcome}: the target page is no longer readable`,
1288
+ });
1289
+ continue;
1290
+ }
1291
+ const disposition = outcome === 'weaken' ? 'weakened' : 'retracted';
1292
+ const replacement = `${renderObservationMarker({ ...marker, disposition })}\n${row.payload}`;
1293
+ const after = replaceObservationBlock(before, marker.id, replacement);
1294
+ if (after === null) {
1295
+ prepared.push({
1296
+ written: { ...written, action: outcome === 'weaken' ? 'would-weaken' : 'would-retract' },
1297
+ draft: null,
1298
+ rejectionReason: `held ${outcome}: the owned block no longer matches its projection`,
1299
+ });
1300
+ continue;
1301
+ }
1302
+ const sealedEvidence = [];
1303
+ for (const locator of marker.evidence) {
1304
+ const fact = ctx.store.db
1305
+ .prepare(`SELECT p.slug, p.rel_path FROM facts f JOIN pages p ON p.id = f.page_id WHERE f.id = ?`)
1306
+ .get(locator.factId);
1307
+ const sourceBytes = fact
1308
+ ? await fsp.readFile(path.join(ctx.config.aknoPath, fact.rel_path), 'utf8').catch(() => null)
1309
+ : null;
1310
+ sealedEvidence.push({
1311
+ slug: fact?.slug ?? `fact:${locator.factId}`,
1312
+ contentHash: sourceBytes === null ? null : sha256(sourceBytes),
1313
+ factId: locator.factId,
1314
+ sourceLineHash: locator.sourceLineHash,
1315
+ proofGroups: locator.proofGroups,
1316
+ });
1317
+ }
1318
+ const inputHash = sha256(JSON.stringify({
1319
+ target: row.source_slug,
1320
+ beforeHash: sha256(before),
1321
+ observationId: row.id,
1322
+ payloadHash: row.payload_hash,
1323
+ issue: row.issue,
1324
+ outcome,
1325
+ evidence: sealedEvidence,
1326
+ }));
1327
+ prepared.push({
1328
+ written,
1329
+ draft: {
1330
+ slug: row.source_slug,
1331
+ relPath: row.rel_path,
1332
+ inputHash,
1333
+ before,
1334
+ after,
1335
+ evidence: sealedEvidence,
1336
+ observationId: row.id,
1337
+ observationSubject: row.subject_entity,
1338
+ observationDisposition: disposition,
1339
+ observationProofCount: row.proof_count,
1340
+ observationOutcome: outcome,
1341
+ observationTargetId: row.id,
1342
+ observationPayloadHash: row.payload_hash,
1343
+ observationQualificationIssue: row.issue,
1344
+ },
1345
+ });
1346
+ }
1347
+ return prepared;
1348
+ }
1349
+ /** Live eligible level-one facts grouped only by exact resolved subject identity. */
1252
1350
  function subjectGroups(ctx, maxSubjects, conflicts) {
1253
- const observations = ctx.config.paths.observations;
1254
1351
  const ineligible = ineligibleConflictClaims(conflicts);
1255
1352
  const rows = ctx.store.db
1256
- .prepare(`SELECT f.subject, f.claim, f.line_start, p.slug, p.updated_at
1353
+ .prepare(`SELECT f.id, f.claim, f.line_start, f.source_line_hash, f.item_id,
1354
+ p.id AS page_id, p.slug, p.updated_at, g.subject_entity,
1355
+ e.canonical_page, cp.slug AS canonical_slug, cp.rel_path AS canonical_rel_path,
1356
+ cp.observe_management AS canonical_observe
1257
1357
  FROM facts f JOIN pages p ON p.id = f.page_id
1358
+ JOIN graph_fact_status g ON g.fact_id = f.id
1359
+ JOIN graph_entities e ON e.id = g.subject_entity
1360
+ JOIN pages cp ON cp.id = e.canonical_page
1258
1361
  WHERE f.valid_to IS NULL
1259
- AND f.subject IS NOT NULL
1260
1362
  AND f.confidence >= 0.5
1261
- -- Only canonical claims. A source page is evidence someone else wrote.
1262
1363
  AND p.role = 'knowledge'
1263
1364
  AND p.derived_hash = p.body_hash
1264
- -- Never self-feeding. An observation is not evidence for another observation.
1265
- AND p.slug != ?
1266
- AND p.slug NOT LIKE ?
1365
+ AND g.eligibility = 'eligible'
1366
+ AND g.traversable = 1
1267
1367
  ORDER BY p.updated_at DESC`)
1268
- .all(observations, `${observations}/%`);
1368
+ .all();
1269
1369
  const groups = new Map();
1270
1370
  for (const row of rows) {
1271
1371
  if (ineligible.has(claimKey(row.slug, row.line_start)))
1272
1372
  continue;
1273
- const subject = row.subject.toLowerCase().replace(/\s+/g, ' ').trim();
1274
- if (subject.length === 0)
1373
+ const target = observationTarget(ctx, row);
1374
+ if (!target)
1375
+ continue;
1376
+ const proofs = [...proofGroupsForFact(ctx.store, row.id, row.page_id, row.item_id)].sort();
1377
+ if (proofs.length === 0)
1275
1378
  continue;
1276
- const folder = row.slug.includes('/') ? row.slug.slice(0, row.slug.indexOf('/')) : '.';
1277
- const key = `${folder}|${subject}`;
1379
+ const key = row.subject_entity;
1278
1380
  const existing = groups.get(key);
1279
1381
  if (existing) {
1280
- if (existing.facts.length < 30)
1281
- existing.facts.push({ claim: row.claim, slug: row.slug });
1382
+ if (existing.targetSlug !== target.slug) {
1383
+ groups.delete(key);
1384
+ continue;
1385
+ }
1386
+ if (existing.facts.length < 30) {
1387
+ existing.facts.push({
1388
+ id: row.id,
1389
+ claim: row.claim,
1390
+ slug: row.slug,
1391
+ sourceLineHash: row.source_line_hash,
1392
+ proofGroups: proofs,
1393
+ });
1394
+ }
1282
1395
  }
1283
1396
  else {
1284
1397
  groups.set(key, {
1285
- subject: row.subject,
1286
- folder,
1287
- slug: folder === '.' ? slugify(subject) : `${slugify(folder)}-${slugify(subject)}`,
1288
- facts: [{ claim: row.claim, slug: row.slug }],
1398
+ subject: target.title,
1399
+ subjectEntity: row.subject_entity,
1400
+ targetSlug: target.slug,
1401
+ targetRelPath: target.relPath,
1402
+ facts: [
1403
+ {
1404
+ id: row.id,
1405
+ claim: row.claim,
1406
+ slug: row.slug,
1407
+ sourceLineHash: row.source_line_hash,
1408
+ proofGroups: proofs,
1409
+ },
1410
+ ],
1289
1411
  });
1290
1412
  }
1291
1413
  }
1292
1414
  return [...groups.values()]
1293
- .filter((group) => new Set(group.facts.map((fact) => fact.slug)).size >= 2)
1415
+ .filter((group) => independentProofGroups(group.facts.map((fact) => ({
1416
+ factId: fact.id,
1417
+ sourceLineHash: fact.sourceLineHash,
1418
+ proofGroups: fact.proofGroups,
1419
+ }))).size >= ctx.config.maintenance.observe.minEvidence)
1294
1420
  .slice(0, maxSubjects);
1295
1421
  }
1296
- /**
1297
- * **Refine, never overwrite** — a changed pattern gets a new dated line. **Add and
1298
- * refine, never delete** — a curator that can delete loses things nobody watched it delete.
1299
- *
1300
- * So the page is only ever appended to, and an observation already on it is left exactly as it
1301
- * is. That is also what makes the phase safe to re-run: a second pass over unchanged facts
1302
- * reports `unchanged` and writes nothing.
1303
- */
1304
- /** Where a subject's observations live. */
1305
- function observationSlug(ctx, pageSlug) {
1306
- return normalizeSlug(`${ctx.config.paths.observations}/${pageSlug}`);
1307
- }
1308
- /**
1309
- * Every observation already written, by page.
1310
- *
1311
- * Read from the files rather than the index because the index is a reading of the files and this
1312
- * runs inside the same cycle that writes them.
1313
- */
1314
- async function allObservations(ctx) {
1315
- const root = path.join(ctx.config.aknoPath, ctx.config.paths.observations);
1316
- const bySlug = new Map();
1317
- const entries = await fsp.readdir(root, { withFileTypes: true, recursive: true }).catch(() => []);
1318
- for (const entry of entries) {
1319
- if (!entry.isFile() || !entry.name.endsWith('.md'))
1320
- continue;
1321
- const abs = path.join(entry.parentPath ?? root, entry.name);
1322
- // Skipped for the same reason `scanTree` and the watcher skip them: a dot path is
1323
- // sync-client or editor state, not an observation. Checked on the whole relative
1324
- // path rather than `entry.name`, because a recursive readdir reports a file inside
1325
- // `.trash/` with a perfectly ordinary basename. It also has to happen before
1326
- // `normalizeSlug` below, which now refuses a dot segment — an unfiltered walk would
1327
- // turn one stray `.backup.md` into a throw inside the nightly cycle.
1328
- if (path
1329
- .relative(root, abs)
1330
- .split(path.sep)
1331
- .some((segment) => segment.startsWith('.'))) {
1332
- continue;
1333
- }
1334
- const body = await fsp.readFile(abs, 'utf8').catch(() => null);
1335
- if (body === null)
1336
- continue;
1337
- const lines = body
1338
- .split('\n')
1339
- .filter((line) => /^- \d{4}-\d{2}-\d{2} — /.test(line))
1340
- .map((line) => line
1341
- .replace(/^- \d{4}-\d{2}-\d{2} — /, '')
1342
- .replace(/\s*\[\[[^\]]*\]\]/g, '')
1343
- .trim())
1344
- .filter(Boolean);
1345
- if (lines.length === 0)
1346
- continue;
1347
- const relative = path.relative(ctx.config.aknoPath, abs).replace(/\.md$/, '');
1348
- bySlug.set(normalizeSlug(relative), lines);
1422
+ function observationTarget(ctx, row) {
1423
+ if (row.canonical_observe === 'integrate') {
1424
+ const page = ctx.store.db.prepare('SELECT title FROM pages WHERE id = ?').get(row.canonical_page);
1425
+ return page ? { slug: row.canonical_slug, relPath: row.canonical_rel_path, title: page.title } : null;
1349
1426
  }
1350
- return bySlug;
1427
+ const topics = ctx.store.db
1428
+ .prepare(`SELECT slug, rel_path, title, about FROM pages
1429
+ WHERE role = 'knowledge' AND observe_management = 'integrate' AND id != ?`)
1430
+ .all(row.canonical_page).filter((page) => JSON.parse(page.about).includes(row.canonical_slug));
1431
+ return topics.length === 1
1432
+ ? { slug: topics[0].slug, relPath: topics[0].rel_path, title: topics[0].title }
1433
+ : null;
1351
1434
  }
1352
- /**
1353
- * Every live claim in the knowledge base, so an "observation" cannot be one of them handed back.
1354
- *
1355
- * Superseded claims are left out: their sentence has already been replaced, and an observation is
1356
- * not a repeat of something the knowledge base no longer says.
1357
- */
1358
- function liveFactClaims(ctx, conflicts = []) {
1359
- const ineligible = ineligibleConflictClaims(conflicts);
1435
+ function indexedObservations(ctx) {
1360
1436
  const rows = ctx.store.db
1361
- .prepare(`SELECT f.claim, f.line_start, p.slug
1362
- FROM facts f JOIN pages p ON p.id = f.page_id
1363
- WHERE f.valid_to IS NULL AND p.derived_hash = p.body_hash`)
1437
+ .prepare(`SELECT source_slug, payload FROM observation_entries
1438
+ WHERE disposition != 'retracted' ORDER BY source_slug, marker_line`)
1364
1439
  .all();
1365
- return rows.filter((row) => !ineligible.has(claimKey(row.slug, row.line_start))).map((row) => row.claim);
1440
+ const result = new Map();
1441
+ for (const row of rows) {
1442
+ const pattern = row.payload
1443
+ .replace(/^- \*\*Observation:\*\*\s*/, '')
1444
+ .replace(/\s+Evidence:\s+(?:\[\[[^\]]+\]\]\s*)+$/, '')
1445
+ .trim();
1446
+ const bucket = result.get(row.source_slug);
1447
+ if (bucket)
1448
+ bucket.push(pattern);
1449
+ else
1450
+ result.set(row.source_slug, [pattern]);
1451
+ }
1452
+ return result;
1366
1453
  }
1367
- async function prepareObservation(ctx, page, observation, evidenceRole = 'knowledge') {
1368
- const slug = normalizeSlug(`${ctx.config.paths.observations}/${page.slug}`);
1369
- const relPath = `${slug}.md`;
1370
- const absPath = path.join(ctx.config.aknoPath, relPath);
1371
- const existing = await fsp.readFile(absPath, 'utf8').catch(() => null);
1372
- // A page is never evidence for itself, whichever caller got here. `reflect` reads the folder it
1373
- // writes into, so it found `principles` among its own sources and cited it — and a claim offered
1374
- // as its own support reads, later, as a claim with support.
1375
- const evidence = observation.evidence.filter((cited) => normalizeSlug(cited) !== slug);
1376
- if (evidence.length === 0) {
1454
+ function indexedObservationRecords(ctx, subject, slug) {
1455
+ const rows = ctx.store.db
1456
+ .prepare(`SELECT id, payload FROM observation_entries
1457
+ WHERE subject_entity = ? AND source_slug = ? AND disposition = 'active' AND eligible = 1
1458
+ ORDER BY marker_line`)
1459
+ .all(subject, slug);
1460
+ return rows.map((row) => ({
1461
+ id: row.id,
1462
+ pattern: row.payload
1463
+ .replace(/^- \*\*Observation:\*\*\s*/, '')
1464
+ .replace(/\s+Evidence:\s+(?:\[\[[^\]]+\]\]\s*)+$/, '')
1465
+ .trim(),
1466
+ }));
1467
+ }
1468
+ async function prepareCoLocatedObservation(ctx, group, observation) {
1469
+ let selected = observation.evidence.flatMap((id) => {
1470
+ const fact = group.facts.find((candidate) => candidate.id === id);
1471
+ return fact ? [fact] : [];
1472
+ });
1473
+ const outcome = observation.outcome ?? 'create';
1474
+ const targetMarker = observation.targetId ? markerFromProjection(ctx.store, observation.targetId) : null;
1475
+ const targetRow = observation.targetId
1476
+ ? ctx.store.db
1477
+ .prepare(`SELECT source_slug, payload FROM observation_entries
1478
+ WHERE id = ? AND subject_entity = ? AND disposition = 'active' AND eligible = 1`)
1479
+ .get(observation.targetId, group.subjectEntity)
1480
+ : undefined;
1481
+ if (outcome !== 'create' && (!targetMarker || !targetRow || targetRow.source_slug !== group.targetSlug)) {
1377
1482
  return {
1378
- written: { slug, pattern: observation.pattern, evidence: [], action: 'unchanged' },
1483
+ written: {
1484
+ slug: group.targetSlug,
1485
+ pattern: observation.pattern,
1486
+ evidence: [],
1487
+ action: 'rejected',
1488
+ },
1379
1489
  draft: null,
1490
+ rejectionReason: 'the requested observation lifecycle target is no longer active and eligible',
1380
1491
  };
1381
1492
  }
1382
- observation = { ...observation, evidence };
1383
- if (existing !== null && existing.includes(observation.pattern)) {
1493
+ if (outcome === 'reinforce' && targetMarker) {
1494
+ selected = await observationFactsForLocators(ctx, [
1495
+ ...targetMarker.evidence,
1496
+ ...selected.map((fact) => ({
1497
+ factId: fact.id,
1498
+ sourceLineHash: fact.sourceLineHash,
1499
+ proofGroups: fact.proofGroups,
1500
+ })),
1501
+ ]);
1502
+ }
1503
+ if (selected.length > 12) {
1384
1504
  return {
1385
- written: { slug, pattern: observation.pattern, evidence: observation.evidence, action: 'unchanged' },
1505
+ written: {
1506
+ slug: group.targetSlug,
1507
+ pattern: observation.pattern,
1508
+ evidence: [],
1509
+ action: 'rejected',
1510
+ },
1386
1511
  draft: null,
1512
+ rejectionReason: 'observation lineage exceeds the 12-fact marker limit',
1387
1513
  };
1388
1514
  }
1389
- const today = new Date().toISOString().slice(0, 10);
1390
- // Deliberately not `- **YYYY-MM-DD** |`, which is read as a timeline event anywhere it
1391
- // appears. An inferred pattern is not something that happened on a date.
1392
- const line = `- ${today} — ${observation.pattern} ${citation(observation.evidence)}`;
1393
- const next = existing === null
1394
- ? newObservationPage(page.title, observation, today)
1395
- : appendObservation(existing, line, observation.evidence);
1515
+ const locators = selected.map((fact) => ({
1516
+ factId: fact.id,
1517
+ sourceLineHash: fact.sourceLineHash,
1518
+ proofGroups: fact.proofGroups,
1519
+ }));
1520
+ const proofCount = independentProofGroups(locators).size;
1521
+ const action = outcome === 'reinforce'
1522
+ ? 'reinforced'
1523
+ : outcome === 'refine'
1524
+ ? 'refined'
1525
+ : outcome === 'split'
1526
+ ? 'split'
1527
+ : 'created';
1396
1528
  const written = {
1397
- slug,
1529
+ slug: group.targetSlug,
1398
1530
  pattern: observation.pattern,
1399
- evidence: observation.evidence,
1400
- action: existing === null ? 'created' : 'refined',
1531
+ evidence: [...new Set(selected.map((fact) => fact.slug))],
1532
+ action,
1533
+ };
1534
+ if (selected.length < 2 || proofCount < ctx.config.maintenance.observe.minEvidence) {
1535
+ return {
1536
+ written: { ...written, action: 'rejected' },
1537
+ draft: null,
1538
+ rejectionReason: `evidence has ${proofCount} independent proof group(s)`,
1539
+ };
1540
+ }
1541
+ const absPath = path.join(ctx.config.aknoPath, group.targetRelPath);
1542
+ const before = await fsp.readFile(absPath, 'utf8').catch(() => null);
1543
+ if (before === null) {
1544
+ return {
1545
+ written: { ...written, action: 'rejected' },
1546
+ draft: null,
1547
+ rejectionReason: 'the admitted target page is no longer readable',
1548
+ };
1549
+ }
1550
+ const existingPattern = targetRow
1551
+ ? targetRow.payload
1552
+ .replace(/^- \*\*Observation:\*\*\s*/, '')
1553
+ .replace(/\s+Evidence:\s+(?:\[\[[^\]]+\]\]\s*)+$/, '')
1554
+ .trim()
1555
+ : null;
1556
+ const finalPattern = outcome === 'reinforce' && existingPattern ? existingPattern : observation.pattern;
1557
+ const marker = {
1558
+ id: targetMarker?.id ?? observationId(group.subjectEntity, finalPattern),
1559
+ subject: group.subjectEntity,
1560
+ disposition: 'active',
1561
+ evidence: locators,
1562
+ proofCount,
1401
1563
  };
1402
- if (next === null) {
1564
+ const block = observationBlock(marker, finalPattern, selected.map((fact) => fact.slug));
1565
+ if (outcome === 'create' && observationMarkerIndexes(before.split(/\r?\n/), marker.id, true).length > 0) {
1566
+ return { written: { ...written, action: 'unchanged' }, draft: null };
1567
+ }
1568
+ let after;
1569
+ if (outcome === 'create') {
1570
+ after = insertObservationBlock(before, block);
1571
+ }
1572
+ else if (outcome === 'split' && targetMarker && existingPattern && observation.splitPattern) {
1573
+ const oldFacts = await observationFactsForLocators(ctx, targetMarker.evidence);
1574
+ if (oldFacts.length !== targetMarker.evidence.length)
1575
+ after = null;
1576
+ else {
1577
+ const superseded = observationBlock({ ...targetMarker, disposition: 'superseded' }, existingPattern, oldFacts.map((fact) => fact.slug));
1578
+ const first = {
1579
+ ...marker,
1580
+ id: observationId(group.subjectEntity, observation.pattern),
1581
+ };
1582
+ const second = {
1583
+ ...marker,
1584
+ id: observationId(group.subjectEntity, observation.splitPattern),
1585
+ };
1586
+ after = replaceObservationBlock(before, targetMarker.id, `${superseded}\n\n${observationBlock(first, observation.pattern, selected.map((fact) => fact.slug))}\n\n${observationBlock(second, observation.splitPattern, selected.map((fact) => fact.slug))}`);
1587
+ }
1588
+ }
1589
+ else {
1590
+ after = replaceObservationBlock(before, marker.id, block);
1591
+ }
1592
+ if (after === null) {
1403
1593
  return {
1404
1594
  written: { ...written, action: 'rejected' },
1405
1595
  draft: null,
1406
- rejectionReason: 'the existing observation page has an unsupported evidence declaration',
1596
+ rejectionReason: 'the owned observation block changed before its lifecycle update was sealed',
1407
1597
  };
1408
1598
  }
1409
- const sealedEvidence = await observationEvidence(ctx, observation.evidence, evidenceRole);
1410
- if (sealedEvidence.length !== observation.evidence.length) {
1599
+ const sealedEvidence = await sealObservationFacts(ctx, selected);
1600
+ if (sealedEvidence.length !== selected.length) {
1411
1601
  return {
1412
1602
  written: { ...written, action: 'rejected' },
1413
1603
  draft: null,
1414
- rejectionReason: 'evidence changed or stopped being live before the observation was sealed',
1604
+ rejectionReason: 'exact evidence changed before the observation was sealed',
1415
1605
  };
1416
1606
  }
1417
1607
  const inputHash = sha256(JSON.stringify({
1418
- slug,
1419
- beforeHash: existing === null ? null : sha256(existing),
1420
- pattern: observation.pattern,
1608
+ target: group.targetSlug,
1609
+ beforeHash: sha256(before),
1610
+ observation: marker,
1421
1611
  evidence: sealedEvidence,
1422
1612
  }));
1423
1613
  return {
1424
1614
  written,
1425
- draft: { slug, relPath, inputHash, before: existing, after: next, evidence: sealedEvidence },
1615
+ draft: {
1616
+ slug: group.targetSlug,
1617
+ relPath: group.targetRelPath,
1618
+ inputHash,
1619
+ before,
1620
+ after,
1621
+ evidence: sealedEvidence,
1622
+ observationId: marker.id,
1623
+ observationSubject: marker.subject,
1624
+ observationDisposition: marker.disposition,
1625
+ observationProofCount: marker.proofCount,
1626
+ observationOutcome: outcome,
1627
+ ...(targetMarker ? { observationTargetId: targetMarker.id } : {}),
1628
+ },
1426
1629
  };
1427
1630
  }
1428
- async function observationEvidence(ctx, slugs, role) {
1631
+ async function observationFactsForLocators(ctx, locators) {
1632
+ const byFact = new Map();
1633
+ for (const locator of locators) {
1634
+ if (byFact.has(locator.factId))
1635
+ continue;
1636
+ const row = ctx.store.db
1637
+ .prepare(`SELECT f.id, f.claim, f.source_line_hash, f.item_id, f.page_id, p.slug
1638
+ FROM facts f JOIN pages p ON p.id = f.page_id
1639
+ WHERE f.id = ? AND f.valid_to IS NULL`)
1640
+ .get(locator.factId);
1641
+ if (!row || row.source_line_hash !== locator.sourceLineHash)
1642
+ continue;
1643
+ const proofGroups = [...proofGroupsForFact(ctx.store, row.id, row.page_id, row.item_id)].sort();
1644
+ if (proofGroups.length !== locator.proofGroups.length ||
1645
+ proofGroups.some((proof) => !locator.proofGroups.includes(proof))) {
1646
+ continue;
1647
+ }
1648
+ byFact.set(row.id, {
1649
+ id: row.id,
1650
+ claim: row.claim,
1651
+ slug: row.slug,
1652
+ sourceLineHash: row.source_line_hash,
1653
+ proofGroups,
1654
+ });
1655
+ }
1656
+ return [...byFact.values()];
1657
+ }
1658
+ async function sealObservationFacts(ctx, facts) {
1429
1659
  const out = [];
1430
- for (const slug of slugs) {
1660
+ for (const fact of facts) {
1431
1661
  const row = ctx.store.db
1432
- .prepare('SELECT rel_path FROM pages WHERE slug = ? AND role = ?')
1433
- .get(slug, role);
1434
- if (!row)
1662
+ .prepare(`SELECT f.source_line_hash, f.valid_to, p.rel_path, p.role, p.derived_hash, p.body_hash
1663
+ FROM facts f JOIN pages p ON p.id = f.page_id WHERE f.id = ?`)
1664
+ .get(fact.id);
1665
+ if (!row ||
1666
+ row.valid_to !== null ||
1667
+ row.source_line_hash !== fact.sourceLineHash ||
1668
+ row.role !== 'knowledge' ||
1669
+ row.derived_hash !== row.body_hash) {
1435
1670
  continue;
1671
+ }
1436
1672
  const content = await fsp
1437
1673
  .readFile(path.join(ctx.config.aknoPath, row.rel_path), 'utf8')
1438
1674
  .catch(() => null);
1439
1675
  if (content === null)
1440
1676
  continue;
1441
- out.push({ slug, contentHash: sha256(content) });
1677
+ out.push({
1678
+ slug: fact.slug,
1679
+ contentHash: sha256(content),
1680
+ factId: fact.id,
1681
+ sourceLineHash: fact.sourceLineHash,
1682
+ proofGroups: fact.proofGroups,
1683
+ });
1442
1684
  }
1443
1685
  return out;
1444
1686
  }
1687
+ /** The configured inference namespace remains the home of the separate L3 principles page. */
1688
+ function inferenceSlug(ctx, pageSlug) {
1689
+ return normalizeSlug(`${ctx.config.paths.observations}/${pageSlug}`);
1690
+ }
1691
+ /**
1692
+ * Every live claim in the knowledge base, so an "observation" cannot be one of them handed back.
1693
+ *
1694
+ * Superseded claims are left out: their sentence has already been replaced, and an observation is
1695
+ * not a repeat of something the knowledge base no longer says.
1696
+ */
1697
+ function liveFactClaims(ctx, conflicts = []) {
1698
+ const ineligible = ineligibleConflictClaims(conflicts);
1699
+ const rows = ctx.store.db
1700
+ .prepare(`SELECT f.claim, f.line_start, p.slug
1701
+ FROM facts f JOIN pages p ON p.id = f.page_id
1702
+ WHERE f.valid_to IS NULL AND p.derived_hash = p.body_hash`)
1703
+ .all();
1704
+ return rows.filter((row) => !ineligible.has(claimKey(row.slug, row.line_start))).map((row) => row.claim);
1705
+ }
1445
1706
  function inferenceWasRejected(ctx, kind, draft) {
1446
1707
  const row = ctx.store.db
1447
1708
  .prepare(`SELECT 1 FROM maintenance_items
@@ -1455,6 +1716,48 @@ function observationFromPlanItem(item) {
1455
1716
  if (operation.type === 'move')
1456
1717
  throw new Error('an inference item cannot move a document');
1457
1718
  const after = operation.type === 'delete' ? operation.before : operation.after;
1719
+ if (item.kind === 'observe') {
1720
+ const metadata = item.evidence.find((entry) => entry.observationId);
1721
+ const projectedId = metadata?.observationId;
1722
+ const lines = after.split(/\r?\n/);
1723
+ const marker = projectedId ? (observationMarkerIndexes(lines, projectedId, true)[0] ?? -1) : -1;
1724
+ const payload = marker >= 0 ? (lines[marker + 1] ?? '') : '';
1725
+ const pattern = /^- \*\*Observation:\*\* (.+?) Evidence: /.exec(payload)?.[1] ?? '';
1726
+ const outcome = metadata?.observationOutcome ?? 'create';
1727
+ const proposed = outcome === 'reinforce'
1728
+ ? 'would-reinforce'
1729
+ : outcome === 'refine'
1730
+ ? 'would-refine'
1731
+ : outcome === 'split'
1732
+ ? 'would-split'
1733
+ : outcome === 'weaken'
1734
+ ? 'would-weaken'
1735
+ : outcome === 'retract'
1736
+ ? 'would-retract'
1737
+ : 'would-create';
1738
+ const completed = outcome === 'reinforce'
1739
+ ? 'reinforced'
1740
+ : outcome === 'refine'
1741
+ ? 'refined'
1742
+ : outcome === 'split'
1743
+ ? 'split'
1744
+ : outcome === 'weaken'
1745
+ ? 'weakened'
1746
+ : outcome === 'retract'
1747
+ ? 'retracted'
1748
+ : 'created';
1749
+ const action = item.status === 'applied'
1750
+ ? completed
1751
+ : ['rejected', 'blocked', 'stale', 'verification_failed'].includes(item.status)
1752
+ ? 'rejected'
1753
+ : proposed;
1754
+ return {
1755
+ slug: item.subject,
1756
+ pattern,
1757
+ evidence: [...new Set(item.evidence.map((entry) => entry.source))],
1758
+ action,
1759
+ };
1760
+ }
1458
1761
  const lastLine = after.trimEnd().split('\n').at(-1) ?? '';
1459
1762
  const pattern = /^- \d{4}-\d{2}-\d{2} — (.+?)(?:\s+\[\[[^\]]+\]\])+$/.exec(lastLine)?.[1] ?? '';
1460
1763
  const proposed = operation.type === 'create' ? 'would-create' : 'would-refine';
@@ -1472,12 +1775,8 @@ function observationFromPlanItem(item) {
1472
1775
  action,
1473
1776
  };
1474
1777
  }
1475
- /**
1476
- * `derived` and `evidence` are the two keys Akno writes on `observations/` pages it
1477
- * authors. They are what makes an inference identifiable as one after the fact — by a reader,
1478
- * by recall's ranking, and by the guard that refuses to feed observations back in.
1479
- */
1480
- function newObservationPage(subject, observation, today) {
1778
+ /** L3 principles retain the legacy inference-page envelope; L2 observations do not use this writer. */
1779
+ function newPrinciplesPage(subject, observation, today) {
1481
1780
  const title = subject.charAt(0).toUpperCase() + subject.slice(1);
1482
1781
  return (`---\ntitle: ${serializeYamlString(title, 'title')}\nderived: true\n` +
1483
1782
  `evidence: ${serializeYamlStringArray(observation.evidence, 'evidence')}\n---\n\n` +
@@ -1486,7 +1785,7 @@ function newObservationPage(subject, observation, today) {
1486
1785
  `- ${today} — ${observation.pattern} ${citation(observation.evidence)}\n`);
1487
1786
  }
1488
1787
  /** Appends the new line and unions the evidence, leaving every existing line alone. */
1489
- function appendObservation(current, line, evidence) {
1788
+ function appendPrinciple(current, line, evidence) {
1490
1789
  const merged = mergeEvidence(current, evidence);
1491
1790
  if (merged === null)
1492
1791
  return null;
@@ -1502,7 +1801,7 @@ function mergeEvidence(current, evidence) {
1502
1801
  return null;
1503
1802
  const front = match[1];
1504
1803
  // An existing key that is not one exact string sequence is user-edited or malformed. Refuse
1505
- // to guess around it and propagate that refusal so no unsupported observation is appended.
1804
+ // to guess around it and propagate that refusal so no unsupported principle is appended.
1506
1805
  if (/^evidence:/m.test(front))
1507
1806
  return null;
1508
1807
  // Add the owned key without round-tripping any neighboring frontmatter.
@@ -1513,13 +1812,6 @@ function mergeEvidence(current, evidence) {
1513
1812
  function citation(evidence) {
1514
1813
  return evidence.map((slug) => `[[${slug}]]`).join(' ');
1515
1814
  }
1516
- function slugify(subject) {
1517
- return (subject
1518
- .toLowerCase()
1519
- .replace(/[^a-z0-9]+/g, '-')
1520
- .replace(/^-+|-+$/g, '')
1521
- .slice(0, 60) || 'pattern');
1522
- }
1523
1815
  // ─── Reflect ────────────────────────────────────────────────────────────────
1524
1816
  /**
1525
1817
  * The third tier — **off by default**, and shipped as an extension point rather than a
@@ -1587,25 +1879,23 @@ async function previewReflectPhase(ctx, report) {
1587
1879
  }
1588
1880
  }
1589
1881
  async function collectPreparedReflections(ctx, report) {
1590
- // The page this phase writes to is under `observations/` like everything it reads, so without
1591
- // excluding it the tier feeds on its own output: from the second night onwards `principles` has a
1592
- // summary, is selected as a source, and is cited as evidence for the principle it already
1593
- // contains. A conclusion that is its own evidence is not a conclusion.
1594
- const target = observationSlug(ctx, PRINCIPLES_SLUG);
1595
- const observations = await allObservations(ctx);
1596
- const ineligibleSources = ineligibleConflictSourceSlugs(report.conflicts);
1882
+ // The L3 page must not feed its own prior conclusions back into reflection. L2 input comes only
1883
+ // from eligible projected markers, while the separate principles page remains write-only here.
1884
+ const target = inferenceSlug(ctx, PRINCIPLES_SLUG);
1885
+ const observations = indexedObservations(ctx);
1597
1886
  const rows = ctx.store.db
1598
- .prepare(`SELECT slug, summary, frontmatter FROM pages
1599
- WHERE (slug = ? OR slug LIKE ?) AND slug != ? AND summary IS NOT NULL
1600
- ORDER BY updated_at DESC LIMIT 40`)
1601
- .all(ctx.config.paths.observations, `${ctx.config.paths.observations}/%`, target).filter((row) => !frontmatterEvidence(row.frontmatter).some((slug) => ineligibleSources.has(slug)));
1887
+ .prepare(`SELECT oe.id, oe.source_slug AS slug, oe.payload, oe.payload_hash, p.rel_path
1888
+ FROM observation_entries oe JOIN pages p ON p.id = oe.source_page
1889
+ WHERE oe.eligible = 1 AND oe.disposition = 'active'
1890
+ ORDER BY p.updated_at DESC, oe.marker_line DESC LIMIT 40`)
1891
+ .all();
1602
1892
  if (rows.length < 2) {
1603
1893
  report.warnings.push('reflect had fewer than two observations to build on — nothing was written');
1604
1894
  return [];
1605
1895
  }
1606
1896
  const result = await runObserveMission({
1607
1897
  subject: 'decision principles',
1608
- facts: rows.map((row) => ({ claim: row.summary, slug: row.slug })),
1898
+ facts: rows.map((row) => ({ id: row.id, claim: row.payload, slug: row.slug })),
1609
1899
  model: ctx.models.derive,
1610
1900
  mission: ctx.config.maintenance.reflect.mission ??
1611
1901
  'State durable decision principles and long-term tendencies, not individual patterns.',
@@ -1613,7 +1903,7 @@ async function collectPreparedReflections(ctx, report) {
1613
1903
  minEvidence: Math.max(3, ctx.config.maintenance.observe.minEvidence),
1614
1904
  // This tier appends to one page every night from observations that rarely change, so without
1615
1905
  // its own previous answers it restates them — the same way `observe` did, one tier up.
1616
- existing: observations.get(target) ?? [],
1906
+ existing: await recordedPrinciples(ctx),
1617
1907
  // A principle that is one of the observation lines verbatim is not a tier above them. Its
1618
1908
  // sources here are page *summaries*, so the lines themselves are not otherwise checked.
1619
1909
  otherObservations: [...observations].flatMap(([slug, lines]) => (slug === target ? [] : lines)),
@@ -1628,7 +1918,7 @@ async function collectPreparedReflections(ctx, report) {
1628
1918
  report.rejected.push(...result.rejected);
1629
1919
  const prepared = [];
1630
1920
  for (const observation of result.observations) {
1631
- const outcome = await prepareObservation(ctx, { title: 'Principles', slug: PRINCIPLES_SLUG }, observation, 'inference');
1921
+ const outcome = await prepareIndexedReflection(ctx, observation, rows);
1632
1922
  prepared.push(outcome);
1633
1923
  if (outcome.rejectionReason) {
1634
1924
  report.rejected.push({ pattern: observation.pattern, reason: outcome.rejectionReason });
@@ -1636,27 +1926,86 @@ async function collectPreparedReflections(ctx, report) {
1636
1926
  }
1637
1927
  return prepared;
1638
1928
  }
1639
- function ineligibleConflictSourceSlugs(conflicts) {
1640
- const keys = ineligibleConflictClaims(conflicts);
1641
- const slugs = new Set();
1642
- for (const conflict of conflicts) {
1643
- for (const claim of conflict.claims) {
1644
- if (keys.has(claimKey(claim.slug, claim.line)))
1645
- slugs.add(claim.slug);
1646
- }
1647
- }
1648
- return slugs;
1929
+ /** L3 is one explicit synthesis page, so read only its owned dated entries. */
1930
+ async function recordedPrinciples(ctx) {
1931
+ const relPath = `${inferenceSlug(ctx, PRINCIPLES_SLUG)}.md`;
1932
+ const body = await fsp.readFile(path.join(ctx.config.aknoPath, relPath), 'utf8').catch(() => null);
1933
+ if (body === null)
1934
+ return [];
1935
+ return body
1936
+ .split(/\r?\n/)
1937
+ .filter((line) => /^- \d{4}-\d{2}-\d{2} — /.test(line))
1938
+ .map((line) => line
1939
+ .replace(/^- \d{4}-\d{2}-\d{2} — /, '')
1940
+ .replace(/\s*\[\[[^\]]*\]\]/g, '')
1941
+ .trim())
1942
+ .filter(Boolean);
1649
1943
  }
1650
- function frontmatterEvidence(frontmatter) {
1651
- try {
1652
- const value = JSON.parse(frontmatter);
1653
- return Array.isArray(value.evidence)
1654
- ? value.evidence.filter((entry) => typeof entry === 'string')
1655
- : [];
1944
+ async function prepareIndexedReflection(ctx, observation, rows) {
1945
+ const selected = observation.evidence.flatMap((id) => {
1946
+ const row = rows.find((candidate) => candidate.id === id);
1947
+ return row ? [row] : [];
1948
+ });
1949
+ const slug = inferenceSlug(ctx, PRINCIPLES_SLUG);
1950
+ const relPath = `${slug}.md`;
1951
+ const absPath = path.join(ctx.config.aknoPath, relPath);
1952
+ const before = await fsp.readFile(absPath, 'utf8').catch(() => null);
1953
+ const evidenceSlugs = [...new Set(selected.map((entry) => entry.slug))];
1954
+ const written = {
1955
+ slug,
1956
+ pattern: observation.pattern,
1957
+ evidence: evidenceSlugs,
1958
+ action: before === null ? 'created' : 'refined',
1959
+ };
1960
+ if (selected.length < Math.max(3, ctx.config.maintenance.observe.minEvidence)) {
1961
+ return {
1962
+ written: { ...written, action: 'rejected' },
1963
+ draft: null,
1964
+ rejectionReason: 'reflection cited too few eligible level-two observations',
1965
+ };
1656
1966
  }
1657
- catch {
1658
- return [];
1967
+ if (before?.includes(observation.pattern)) {
1968
+ return { written: { ...written, action: 'unchanged' }, draft: null };
1659
1969
  }
1970
+ const today = new Date().toISOString().slice(0, 10);
1971
+ const line = `- ${today} — ${observation.pattern} ${citation(evidenceSlugs)}`;
1972
+ const pageCandidate = { ...observation, evidence: evidenceSlugs };
1973
+ const after = before === null
1974
+ ? newPrinciplesPage('Principles', pageCandidate, today)
1975
+ : appendPrinciple(before, line, evidenceSlugs);
1976
+ if (after === null) {
1977
+ return {
1978
+ written: { ...written, action: 'rejected' },
1979
+ draft: null,
1980
+ rejectionReason: 'the existing principles page has an unsupported evidence declaration',
1981
+ };
1982
+ }
1983
+ const evidence = [];
1984
+ for (const row of selected) {
1985
+ const bytes = await fsp.readFile(path.join(ctx.config.aknoPath, row.rel_path), 'utf8').catch(() => null);
1986
+ if (bytes === null)
1987
+ continue;
1988
+ evidence.push({
1989
+ slug: row.slug,
1990
+ contentHash: sha256(bytes),
1991
+ reflectionObservationId: row.id,
1992
+ reflectionPayloadHash: row.payload_hash,
1993
+ });
1994
+ }
1995
+ if (evidence.length !== selected.length) {
1996
+ return {
1997
+ written: { ...written, action: 'rejected' },
1998
+ draft: null,
1999
+ rejectionReason: 'a level-two source changed before reflection was sealed',
2000
+ };
2001
+ }
2002
+ const inputHash = sha256(JSON.stringify({
2003
+ slug,
2004
+ before: before === null ? null : sha256(before),
2005
+ pattern: observation.pattern,
2006
+ evidence,
2007
+ }));
2008
+ return { written, draft: { slug, relPath, inputHash, before, after, evidence } };
1660
2009
  }
1661
2010
  /** A journal entry, as the log wants it: which phase, and what the write added. */
1662
2011
  function asApplied(phase, file) {