pi-canon 0.2.4 → 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.
@@ -0,0 +1,251 @@
1
+ /* Per-store article schema: schema.json at the store root, a data contract rather
2
+ than buried code. The file is created with these defaults made explicit the first
3
+ time the store persists anything, so its owner can see it, edit it per project,
4
+ and point other tools at the same contract this package enforces.
5
+
6
+ Enforcement is asymmetric on purpose. A rule marked required rejects the write
7
+ that violates it, because a missing required field is exactly the class of miss
8
+ that soft advice has demonstrably failed to prevent; every other rule warns, on
9
+ write and on read, so an agent reading a noncompliant article learns it can heal
10
+ what it is holding. A required violation the current write did not touch also
11
+ only warns: a capsule-only update must not be held hostage to a legacy body. */
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import type { Article } from "./store.ts";
16
+
17
+ export const SCHEMA_FILE = "schema.json";
18
+ export const SCHEMA_VERSION = 1;
19
+
20
+ export interface SchemaRule {
21
+ required?: boolean;
22
+ min_chars?: number;
23
+ max_chars?: number;
24
+ hint?: string;
25
+ }
26
+
27
+ /* Relations rules are about the reference graph, not any one field. The store
28
+ declares them once here; each tool enforces the ones it can see. This package
29
+ sees an article's own outgoing [[references]] at the write boundary, so it
30
+ enforces refs; orphan and children need the whole graph and are enforced by
31
+ graph-reading tools (canon-atlas) from this same file. */
32
+ export interface RefsRule {
33
+ required?: boolean;
34
+ min_count?: number;
35
+ hint?: string;
36
+ }
37
+ export interface OrphanRule {
38
+ warn?: boolean;
39
+ hint?: string;
40
+ }
41
+ export interface ChildrenRule {
42
+ listed?: boolean;
43
+ hint?: string;
44
+ }
45
+ export interface CanonRelations {
46
+ refs?: RefsRule;
47
+ orphan?: OrphanRule;
48
+ children?: ChildrenRule;
49
+ }
50
+
51
+ export interface CanonSchema {
52
+ capsule?: SchemaRule;
53
+ title?: SchemaRule;
54
+ body?: SchemaRule;
55
+ relations?: CanonRelations;
56
+ }
57
+
58
+ const FIELD_NAMES = ["capsule", "title", "body"] as const;
59
+ const RULE_KEYS = new Set(["required", "min_chars", "max_chars", "hint"]);
60
+ const RELATION_KEYS: Record<string, Set<string>> = {
61
+ refs: new Set(["required", "min_count", "hint"]),
62
+ orphan: new Set(["warn", "hint"]),
63
+ children: new Set(["listed", "hint"]),
64
+ };
65
+
66
+ /* The shipped defaults, written into the file verbatim. Nothing is required and the
67
+ caps mirror what the advisory lint has always said, so a store that never edits
68
+ this file behaves as it always did, just with the contract visible on disk. */
69
+ const DEFAULT_FILE = `{
70
+ "schema_version": 1,
71
+ "about": "Article schema for this canon store. pi-canon enforces it at the tool boundary: a rule marked required rejects a write that violates it (judged on what the write touches, and on everything when the article is first created); every other rule warns on write and is reported on read, so agents can heal what they are holding. Other tools reading this store can enforce the same contract from this file. Fields: capsule (the front matter line surfaced on touch), title (the body's leading # heading), body. Rule keys: required, min_chars, max_chars, hint. Relations rules live under relations: refs (required, min_count) over an article's own outgoing references, orphan (warn) and children (listed) over the whole graph; each tool enforces the rules it can see, so refs holds here and the graph rules hold in graph-reading tools like canon-atlas. Delete a rule to drop it; delete this file to disable schema checks.",
72
+ "article": {
73
+ "capsule": { "required": false, "max_chars": 1000, "hint": "One dense line of current truth; surfacing injects it when the asset is touched." },
74
+ "title": { "required": false, "hint": "Start the body with a # heading naming the asset." },
75
+ "body": { "max_chars": 20000, "hint": "Past this, go hierarchical: keep this article as the summary and router, and move detail into children at addresses under it." }
76
+ },
77
+ "relations": {
78
+ "refs": { "required": false, "hint": "Outgoing [[references]] authored by this article. required rejects a write whose body cites nothing; min_count warns under a floor." },
79
+ "orphan": { "warn": false, "hint": "Warn when no other article references this one. Needs the whole graph, so graph-reading tools enforce it." },
80
+ "children": { "listed": false, "hint": "Warn when an article does not reference each direct child under its address. Enforced by graph-reading tools." }
81
+ }
82
+ }
83
+ `;
84
+
85
+ /* Created only when absent, so an edited or deleted file is never fought over. */
86
+ export function ensureSchemaFile(root: string): void {
87
+ try {
88
+ writeFileSync(join(root, SCHEMA_FILE), DEFAULT_FILE, { flag: "wx" });
89
+ } catch {
90
+ /* Exists already, or the root is unwritable; either way not this call's problem. */
91
+ }
92
+ }
93
+
94
+ /* The schema as declared, plus every problem with the declaration itself. A malformed
95
+ file fails open and loud: rules stop being enforced, and the caller says so, because
96
+ a schema the owner believes is enforced while a typo disabled it is the worst state. */
97
+ export function loadSchema(root: string): { schema: CanonSchema | undefined; problems: string[] } {
98
+ const file = join(root, SCHEMA_FILE);
99
+ if (!existsSync(file)) return { schema: undefined, problems: [] };
100
+ let raw: unknown;
101
+ try {
102
+ raw = JSON.parse(readFileSync(file, "utf8"));
103
+ } catch (error) {
104
+ return {
105
+ schema: undefined,
106
+ problems: [`${SCHEMA_FILE} is not valid JSON (${(error as Error).message}); its rules are not being enforced.`],
107
+ };
108
+ }
109
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
110
+ return { schema: undefined, problems: [`${SCHEMA_FILE} must hold a JSON object; its rules are not being enforced.`] };
111
+ }
112
+ const problems: string[] = [];
113
+ /* A missing article block is an empty one, not an early exit: a schema may
114
+ carry only relations rules. */
115
+ const declared = (raw as Record<string, unknown>).article ?? {};
116
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
117
+ return { schema: undefined, problems: [`${SCHEMA_FILE}: "article" must be an object; its rules are not being enforced.`] };
118
+ }
119
+ const schema: CanonSchema = {};
120
+ for (const [name, value] of Object.entries(declared as Record<string, unknown>)) {
121
+ if (!(FIELD_NAMES as readonly string[]).includes(name)) {
122
+ problems.push(`${SCHEMA_FILE}: unknown article field "${name}" is ignored (fields: ${FIELD_NAMES.join(", ")}).`);
123
+ continue;
124
+ }
125
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
126
+ problems.push(`${SCHEMA_FILE}: rule for "${name}" must be an object and is ignored.`);
127
+ continue;
128
+ }
129
+ const rule: SchemaRule = {};
130
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
131
+ if (!RULE_KEYS.has(key)) {
132
+ problems.push(`${SCHEMA_FILE}: unknown rule key "${name}.${key}" is ignored (keys: required, min_chars, max_chars, hint).`);
133
+ } else if (key === "required" && typeof val === "boolean") rule.required = val;
134
+ else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
135
+ else if (key === "hint" && typeof val === "string") rule.hint = val;
136
+ else problems.push(`${SCHEMA_FILE}: "${name}.${key}" has the wrong type and is ignored.`);
137
+ }
138
+ schema[name as "capsule" | "title" | "body"] = rule;
139
+ }
140
+ const rel = (raw as Record<string, unknown>).relations;
141
+ if (rel !== undefined) {
142
+ if (typeof rel !== "object" || rel === null || Array.isArray(rel)) {
143
+ problems.push(`${SCHEMA_FILE}: "relations" must be an object and is ignored.`);
144
+ } else {
145
+ const relations: CanonRelations = {};
146
+ for (const [name, value] of Object.entries(rel as Record<string, unknown>)) {
147
+ const keys = RELATION_KEYS[name];
148
+ if (!keys) {
149
+ problems.push(`${SCHEMA_FILE}: unknown relations field "${name}" is ignored (fields: refs, orphan, children).`);
150
+ continue;
151
+ }
152
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
153
+ problems.push(`${SCHEMA_FILE}: rule for "relations.${name}" must be an object and is ignored.`);
154
+ continue;
155
+ }
156
+ const rule: Record<string, boolean | number | string> = {};
157
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
158
+ if (!keys.has(key)) {
159
+ problems.push(`${SCHEMA_FILE}: unknown rule key "relations.${name}.${key}" is ignored (keys: ${[...keys].join(", ")}).`);
160
+ } else if ((key === "required" || key === "warn" || key === "listed") && typeof val === "boolean") rule[key] = val;
161
+ else if (key === "min_count" && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
162
+ else if (key === "hint" && typeof val === "string") rule[key] = val;
163
+ else problems.push(`${SCHEMA_FILE}: "relations.${name}.${key}" has the wrong type and is ignored.`);
164
+ }
165
+ relations[name as keyof CanonRelations] = rule;
166
+ }
167
+ schema.relations = relations;
168
+ }
169
+ }
170
+ return { schema, problems };
171
+ }
172
+
173
+ /* The outgoing references an article authors: [[wikilink]] targets in the body,
174
+ fenced and inline code stripped (examples are not citations), deduplicated
175
+ case-insensitively. The write boundary can always see these, whatever else it
176
+ cannot see of the graph. */
177
+ export function outgoingOf(body: string): string[] {
178
+ const stripped = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
179
+ const out = new Set<string>();
180
+ for (const m of stripped.matchAll(/\[\[([^\]|#\n]+)(?:[|#][^\]\n]*)?\]\]/g)) {
181
+ const target = m[1].trim().toLowerCase();
182
+ if (target) out.add(target);
183
+ }
184
+ return [...out].sort();
185
+ }
186
+
187
+ /* The title is the body's leading # heading; the write interface has no separate
188
+ title field on purpose, so the rule checks the one place a title can live. */
189
+ export function titleOf(body: string): string | undefined {
190
+ const first = body.split(/\r?\n/).find((line) => line.trim() !== "");
191
+ const heading = first === undefined ? undefined : /^#\s+(.+)$/.exec(first.trim());
192
+ return heading ? heading[1].trim() : undefined;
193
+ }
194
+
195
+ export interface Touched {
196
+ capsule: boolean;
197
+ body: boolean;
198
+ refs: boolean;
199
+ created: boolean;
200
+ }
201
+
202
+ /* No write in flight: every violation is reportable but none can reject. */
203
+ export const READ_ONLY: Touched = { capsule: false, body: false, refs: false, created: false };
204
+
205
+ export interface Verdict {
206
+ rejections: string[];
207
+ warnings: string[];
208
+ }
209
+
210
+ export function checkArticle(article: Article, schema: CanonSchema, touched: Touched): Verdict {
211
+ const verdict: Verdict = { rejections: [], warnings: [] };
212
+ const fields: { name: keyof CanonSchema; value: string | undefined; missing: string; carrier: "capsule" | "body" }[] = [
213
+ { name: "capsule", value: article.capsule || undefined, missing: "required and empty", carrier: "capsule" },
214
+ { name: "title", value: titleOf(article.body), missing: "required and the body has no leading # heading", carrier: "body" },
215
+ { name: "body", value: article.body || undefined, missing: "required and empty", carrier: "body" },
216
+ ];
217
+ for (const field of fields) {
218
+ const rule = schema[field.name];
219
+ if (!rule) continue;
220
+ const hint = rule.hint ? ` ${rule.hint}` : "";
221
+ if (rule.required && field.value === undefined) {
222
+ const message = `${field.name}: ${field.missing}.${hint}`;
223
+ if (touched.created || touched[field.carrier]) verdict.rejections.push(message);
224
+ else verdict.warnings.push(message);
225
+ continue;
226
+ }
227
+ if (field.value === undefined) continue;
228
+ if (rule.min_chars !== undefined && field.value.length < rule.min_chars) {
229
+ verdict.warnings.push(`${field.name}: ${field.value.length} chars (min ${rule.min_chars}).${hint}`);
230
+ }
231
+ if (rule.max_chars !== undefined && field.value.length > rule.max_chars) {
232
+ verdict.warnings.push(`${field.name}: ${field.value.length} chars (max ${rule.max_chars}).${hint}`);
233
+ }
234
+ }
235
+ /* The one relations rule this boundary can see whole: the article's own
236
+ citations. required follows the same asymmetry as the fields, rejecting only
237
+ the write that changed the reference set or created the article. */
238
+ const refsRule = schema.relations && schema.relations.refs;
239
+ if (refsRule) {
240
+ const cited = outgoingOf(article.body).length;
241
+ const hint = refsRule.hint ? ` ${refsRule.hint}` : "";
242
+ if (refsRule.required && cited === 0) {
243
+ const message = `refs: required and the body references nothing.${hint}`;
244
+ if (touched.created || touched.refs) verdict.rejections.push(message);
245
+ else verdict.warnings.push(message);
246
+ } else if (refsRule.min_count !== undefined && cited < refsRule.min_count) {
247
+ verdict.warnings.push(`refs: ${cited} outgoing (min ${refsRule.min_count}).${hint}`);
248
+ }
249
+ }
250
+ return verdict;
251
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
+ import { ensureSchemaFile } from "./schema.ts";
9
10
 
10
11
  export interface Article {
11
12
  path: string;
@@ -235,7 +236,10 @@ export class CanonStore {
235
236
  return parts.sort().join("|");
236
237
  }
237
238
 
238
- write(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
239
+ /* The article this write would store, without storing it. Public so the tool can
240
+ hold a would-be article against the store's schema and reject before anything
241
+ touches disk; write() persists exactly what compose() returns. */
242
+ compose(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
239
243
  path = contain(path);
240
244
  const prior = this.read(path);
241
245
  /* Agents sometimes paste a whole file as the body, front matter included; stored
@@ -246,7 +250,7 @@ export class CanonStore {
246
250
  if (block && block[1].split(/\r?\n/).every((line) => /^[\w-]+:\s|^\s*$/.test(line))) {
247
251
  body = body.slice(block[0].length).trimStart();
248
252
  }
249
- const article: Article = {
253
+ return {
250
254
  path,
251
255
  capsule: (fields.capsule ?? prior?.capsule ?? "").replace(/\s*\n\s*/g, " ").trim(),
252
256
  updated: today(),
@@ -254,8 +258,13 @@ export class CanonStore {
254
258
  extra: prior?.extra ?? [],
255
259
  body,
256
260
  };
257
- const file = this.fileFor(path);
261
+ }
262
+
263
+ write(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
264
+ const article = this.compose(path, fields);
265
+ const file = this.fileFor(article.path);
258
266
  mkdirSync(dirname(file), { recursive: true });
267
+ ensureSchemaFile(this.root);
259
268
  writeFileSync(file, serialize(article));
260
269
  return article;
261
270
  }
@@ -272,6 +281,7 @@ export class CanonStore {
272
281
  const slug =
273
282
  (entry.slug ?? "entry").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "entry";
274
283
  mkdirSync(this.journalDir, { recursive: true });
284
+ ensureSchemaFile(this.root);
275
285
  /* An explicit instant, because the filename cannot carry one. Entries are named by
276
286
  date plus slug with -2, -3 for collisions, and sorting those lexicographically puts
277
287
  -10 before -2 and the unsuffixed name after every suffixed one, so "the newest
@@ -30,6 +30,52 @@ function trace(kind: string, data: Record<string, unknown>): void {
30
30
 
31
31
  const PATHLIKE = /(?:^|[\s"'`=:,([{])(\/?[\w.@-]+(?:\/[\w.@-]+)+)/g;
32
32
 
33
+ /* A touch says what knowledge should surface. It does not by itself say that current
34
+ truth changed. The write-after reminder needs positive evidence of a modifying tool,
35
+ otherwise Read, Grep, and inspection-only shell calls turn an optional maintenance
36
+ prompt into a false stop. This list stays deliberately small: an unknown tool may
37
+ surface an article, but it cannot create an update obligation merely by naming a path. */
38
+ const MUTATING_TOOL_SUFFIXES = [
39
+ "applypatch",
40
+ "write",
41
+ "writefile",
42
+ "edit",
43
+ "editfile",
44
+ "multiedit",
45
+ "notebookedit",
46
+ "createfile",
47
+ "deletefile",
48
+ "movefile",
49
+ "renamefile",
50
+ "replaceinfile",
51
+ "strreplaceeditor",
52
+ ];
53
+
54
+ const SHELL_TOOL_SUFFIXES = ["bash", "shell", "exec", "execcommand", "runcommand", "terminal"];
55
+
56
+ function stringsIn(value: unknown): string[] {
57
+ if (typeof value === "string") return [value];
58
+ if (Array.isArray(value)) return value.flatMap(stringsIn);
59
+ if (!value || typeof value !== "object") return [];
60
+ return Object.values(value).flatMap(stringsIn);
61
+ }
62
+
63
+ export function changesAssets(toolName: unknown, input: unknown): boolean {
64
+ if (typeof toolName !== "string" || !toolName) return false;
65
+ const compact = toolName.toLowerCase().replace(/[^a-z0-9]+/g, "");
66
+ if (MUTATING_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
67
+ if (!SHELL_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return false;
68
+
69
+ const command = stringsIn(input).join("\n");
70
+ if (/\btools\.apply_patch\s*\(/.test(command)) return true;
71
+ if (/(?:^|[\n;&|]\s*)(?:sudo\s+)?(?:[\w.-]+\/)*(?:apply_patch|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|install|truncate|dd|patch)\b/m.test(command)) {
72
+ return true;
73
+ }
74
+ if (/\bsed\s+(?:-[a-z]*i[a-z]*\b|--in-place(?:=|\b))/i.test(command)) return true;
75
+ if (/\bperl\s+-[a-z]*pi[a-z]*\b/i.test(command)) return true;
76
+ return /\bgit\s+(?:apply|checkout|restore|reset|clean|mv|rm)\b/.test(command);
77
+ }
78
+
33
79
  /* Presence marks -----------------------------------------------------------------
34
80
 
35
81
  "Seen" used to mean "we sent it once", which is only the same thing as "the agent
@@ -510,6 +556,18 @@ export class Surfacer {
510
556
  }
511
557
  }
512
558
 
559
+ /* Record a successful modifying call separately from a touch. A read still stages the
560
+ governing article, but it creates no update obligation. */
561
+ markChanged(assets: string[]): void {
562
+ for (const asset of assets) {
563
+ const { mount, absolute } = this.locate(asset);
564
+ const article = mount.store.resolve(absolute, mount.dir);
565
+ if (!article) continue;
566
+ const key = mount.name ? `${mount.name}:${article.path}` : article.path;
567
+ this.pendingUpdates.add(key);
568
+ }
569
+ }
570
+
513
571
  /* Stage each newly touched governing article. Nothing is sent or spent here. */
514
572
  collect(assets: string[]): void {
515
573
  for (const asset of assets) {
@@ -517,7 +575,6 @@ export class Surfacer {
517
575
  const article = mount.store.resolve(absolute, mount.dir);
518
576
  if (!article) continue;
519
577
  const key = mount.name ? `${mount.name}:${article.path}` : article.path;
520
- this.pendingUpdates.add(key);
521
578
  if (this.seen.has(key) || this.staged.has(key)) continue;
522
579
  const stamp = article.updated ? ` (updated ${article.updated})` : "";
523
580
  this.staged.set(key, { capsule: article.capsule, stamp, asset });
@@ -607,8 +664,8 @@ export class Surfacer {
607
664
  trace("delivery-undone", {});
608
665
  }
609
666
 
610
- /* The write-after half of the doctrine: every governing article touched since its
611
- last update draws one reminder, then the slate clears for the next batch. */
667
+ /* The write-after half of the doctrine: every governing article named by a successful
668
+ modifying call since its last update draws one reminder, then the slate clears. */
612
669
  settleNudge(): string | undefined {
613
670
  const stale = [...this.pendingUpdates];
614
671
  this.lastNudge = stale;
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
- import { advise, unretained } from "./lint.ts";
6
+ import { advise, orphaned, unretained } from "./lint.ts";
7
+ import { checkArticle, loadSchema, outgoingOf, READ_ONLY, SCHEMA_FILE } from "./schema.ts";
7
8
  import { contained, normalize, type CanonStore } from "./store.ts";
8
9
  import { type Candidate, LexicalRetriever, RULE_SCOPE } from "./retrieval.ts";
9
10
  import type { Mount, Surfacer } from "./surfacing.ts";
@@ -33,6 +34,13 @@ export const CANON_TOOL_PARAMETERS = {
33
34
  },
34
35
  capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
35
36
  query: { type: "string", description: "search: words to look for, across articles and the journal." },
37
+ journal: {
38
+ type: "boolean",
39
+ description:
40
+ "search: true to include journal entries in the results. Off by default because " +
41
+ "events are history, not current truth; the result names how many entries matched " +
42
+ "so you can opt in when the history is the point.",
43
+ },
36
44
  scope: {
37
45
  type: "string",
38
46
  enum: ["rule", "asset"],
@@ -156,10 +164,19 @@ function filingTail(retrieval: string): string {
156
164
  named. Neither is decoration.
157
165
 
158
166
  Ranking reuses LexicalRetriever rather than growing a second notion of relevance, so search
159
- and recommendation cannot drift apart. */
167
+ and recommendation cannot drift apart.
168
+
169
+ The journal is opt-in (Shane, 2026-08-20): events are history, not current truth,
170
+ and R1 measured what including them by default cost, journal entries about an
171
+ event crowding out the article that carries its current truth for 8 to 20 points
172
+ of governing-article recall at realistic query lengths. Default search ranks
173
+ articles alone, never reads a journal body, and says the journal exists; with
174
+ journal true the window is split, articles up to half, journal the rest,
175
+ whichever side runs short ceding its slots. Current truth first, always. */
160
176
  const SEARCH_RESULTS = 10;
177
+ const ARTICLE_SLOTS = 5;
161
178
 
162
- function search(store: CanonStore, query: string): string {
179
+ function search(store: CanonStore, query: string, includeJournal: boolean): string {
163
180
  if (!query.trim()) return "search needs a query.";
164
181
  const articles: Candidate[] = [];
165
182
  for (const path of store.list()) {
@@ -175,20 +192,26 @@ function search(store: CanonStore, query: string): string {
175
192
  }
176
193
  }
177
194
  /* Journal entries enter the same index under a `journal/` key so one ranking covers both.
178
- The key is an index handle, never an address: it is not something `read` accepts. */
179
- const entries = store.journalEntries();
195
+ The key is an index handle, never an address: it is not something `read` accepts.
196
+ Built only on opt-in, so a default search never pays for reading every entry body. */
180
197
  const byKey = new Map<string, { logged: string; subjects: string[]; body: string }>();
181
- const journal: Candidate[] = entries.map((entry) => {
182
- const key = `journal/${entry.name.replace(/\.md$/, "")}`;
183
- byKey.set(key, entry);
184
- return {
185
- path: key,
186
- capsule: entry.subjects.join(", "),
187
- body: entry.body,
188
- updated: entry.logged,
189
- declared: false,
190
- };
191
- });
198
+ const journal: Candidate[] = !includeJournal
199
+ ? []
200
+ : store.journalEntries().map((entry) => {
201
+ const key = `journal/${entry.name.replace(/\.md$/, "")}`;
202
+ byKey.set(key, entry);
203
+ return {
204
+ path: key,
205
+ capsule: entry.subjects.join(", "),
206
+ body: entry.body,
207
+ updated: entry.logged,
208
+ declared: false,
209
+ };
210
+ });
211
+ const invitation =
212
+ !includeJournal && store.journalCount() > 0
213
+ ? "The journal was not searched; pass journal true to search events too."
214
+ : "";
192
215
 
193
216
  const all = [...articles, ...journal];
194
217
  if (!all.length) return "Nothing in the canon yet.";
@@ -196,9 +219,18 @@ function search(store: CanonStore, query: string): string {
196
219
  retriever.index(all);
197
220
  const scored = retriever.score(query, all);
198
221
  const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
199
- if (!ranked.length) return `Nothing matches "${query}".`;
222
+ if (!ranked.length) return [`Nothing matches "${query}".`, invitation].filter(Boolean).join(" ");
223
+
224
+ const articleRanked = ranked.filter(([key]) => !byKey.has(key));
225
+ const journalRanked = ranked.filter(([key]) => byKey.has(key));
226
+ const articleQuota = Math.min(
227
+ articleRanked.length,
228
+ Math.max(ARTICLE_SLOTS, SEARCH_RESULTS - journalRanked.length),
229
+ );
230
+ const journalQuota = Math.min(journalRanked.length, SEARCH_RESULTS - articleQuota);
231
+ const chosen = [...articleRanked.slice(0, articleQuota), ...journalRanked.slice(0, journalQuota)];
200
232
 
201
- const lines = ranked.slice(0, SEARCH_RESULTS).map(([key]) => {
233
+ const lines = chosen.map(([key]) => {
202
234
  const entry = byKey.get(key);
203
235
  if (entry) {
204
236
  const subjects = entry.subjects.length ? ` (${entry.subjects.join(", ")})` : "";
@@ -208,9 +240,10 @@ function search(store: CanonStore, query: string): string {
208
240
  return `${key}: ${article?.capsule || excerpt(article?.body ?? "")}`;
209
241
  });
210
242
  /* Say what was dropped. A silent cap reads as "that is everything". */
211
- if (ranked.length > SEARCH_RESULTS) {
212
- lines.push(`... ${ranked.length - SEARCH_RESULTS} more matched; narrow the query to see them.`);
243
+ if (ranked.length > chosen.length) {
244
+ lines.push(`... ${ranked.length - chosen.length} more matched; narrow the query to see them.`);
213
245
  }
246
+ if (invitation) lines.push(invitation);
214
247
  return lines.join("\n");
215
248
  }
216
249
 
@@ -253,7 +286,18 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
253
286
  const index = recent.length
254
287
  ? `\n\njournal: ${recent.join(", ")}${earlier ? ` and ${earlier} earlier` : ""}`
255
288
  : "";
256
- return `${title}\n${head}\n\n${article.body}`.trim() + index;
289
+ /* Reads never reject, but they do report: an agent holding a noncompliant
290
+ article is the one agent positioned to heal it, and silence here is how a
291
+ store drifts out of its own contract one read at a time. */
292
+ const { schema, problems } = loadSchema(store.root);
293
+ const standing = schema ? checkArticle(article, schema, READ_ONLY) : { rejections: [], warnings: [] };
294
+ const issues = [...standing.warnings, ...problems];
295
+ const report = issues.length
296
+ ? `\n\nschema (${SCHEMA_FILE}): ${issues.join(" ")} This article can be healed with a write.`
297
+ : "";
298
+ const missing = orphaned(mount.dir, article);
299
+ const orphan = missing ? `\n\n${missing}` : "";
300
+ return `${title}\n${head}\n\n${article.body}`.trim() + index + report + orphan;
257
301
  }
258
302
  case "write": {
259
303
  if (!path) return "write needs a path.";
@@ -266,7 +310,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
266
310
  prior state; which fields this call happened to set is a separate question and is
267
311
  answered separately below. */
268
312
  const prior = store.read(path);
269
- const article = store.write(path, {
313
+ const fields = {
270
314
  capsule: params.capsule ? String(params.capsule) : undefined,
271
315
  body: params.body ? String(params.body) : undefined,
272
316
  /* "asset" is the way back. The enum is the only vocabulary the model has, so
@@ -274,16 +318,93 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
274
318
  one: every other input falls through to undefined, which means untouched. It
275
319
  stores empty, which is the default state, the address being the claim. */
276
320
  scope: params.scope === "asset" ? "" : params.scope ? String(params.scope) : undefined,
277
- });
321
+ };
322
+ /* The store's declared contract, held against the article this write WOULD
323
+ store, before anything touches disk. Only a required rule the write itself
324
+ touched (or a brand new article) rejects; everything else warns, so the write
325
+ still lands and the agent still learns. */
326
+ const { schema, problems } = loadSchema(store.root);
327
+ const composed = store.compose(path, fields);
328
+ const verdict = schema
329
+ ? checkArticle(composed, schema, {
330
+ capsule: fields.capsule !== undefined,
331
+ body: fields.body !== undefined,
332
+ /* Touched means the reference SET changed, not that a body was sent: a
333
+ body edit that keeps its citations must not re-litigate them. */
334
+ refs: outgoingOf(prior ? prior.body : "").join("\n") !== outgoingOf(composed.body).join("\n"),
335
+ created: !prior,
336
+ })
337
+ : { rejections: [], warnings: [] };
338
+ if (verdict.rejections.length) {
339
+ return [
340
+ `Write rejected by this store's ${SCHEMA_FILE}:`,
341
+ ...verdict.rejections.map((line) => `- ${line}`),
342
+ "Nothing was written. Fix the listed fields and write again.",
343
+ ].join("\n");
344
+ }
345
+ /* A write that changes nothing is a restatement, not a change. Measured (W1j):
346
+ restating the current state through the write path was the one store
347
+ corruption no content rule could catch, because no field differs; the only
348
+ thing it changed was the freshness stamp, which then lied. So equality is
349
+ checked here, mechanically, and `updated` keeps meaning what it says. */
350
+ if (
351
+ prior &&
352
+ composed.capsule === prior.capsule &&
353
+ composed.scope === prior.scope &&
354
+ composed.body.trimEnd() === prior.body.trimEnd()
355
+ ) {
356
+ surfacer.markUpdated(
357
+ qualify(composed.path),
358
+ [params.capsule, params.body].filter(Boolean).map(String).join("\n"),
359
+ );
360
+ return [
361
+ `${qualify(composed.path)} is already current: this write matches the stored article, ` +
362
+ `so nothing was rewritten and updated stays ${prior.updated}.`,
363
+ ...verdict.warnings.map((line) => `schema: ${line}`),
364
+ ...problems,
365
+ ].join("\n");
366
+ }
367
+ const article = store.write(path, fields);
368
+ /* When a rewrite grows the body, the result says so and restates the split.
369
+ Measured over three captures, two arms each, on byte-identical
370
+ eight-session lineages: writers narrate history into articles until the
371
+ store outgrows the raw transcripts it distills, and prompt-side guidance
372
+ does not change the habit. The arm that got this line ended with fewer
373
+ standing superseded values in all three, 51 and 45 and 71 of 96 against
374
+ 88 and 87 and 85. Take the DIRECTION and not the size. The third capture
375
+ counterbalanced the arm order and kept the direction while losing most of
376
+ the magnitude, and re-running a matched untreated cell moved its median
377
+ store 39 percent, so this instrument does not measure its own magnitudes
378
+ reliably. Two readers in 96 sessions were harmed by a stale value, one
379
+ from each arm, so this line is not known to protect readers. It is also
380
+ not one exposure: it fired after 117, 99, and 126 of that arm's 198, 184,
381
+ and 207 writes. Any growth fires; that exact behavior is what was
382
+ measured. Creation is not growth, and a capsule-only write never grows
383
+ the stored body. */
384
+ const priorBytes = prior ? Buffer.byteLength(prior.body.trimEnd()) : null;
385
+ const nextBytes = Buffer.byteLength(composed.body.trimEnd());
386
+ const growth =
387
+ priorBytes !== null && nextBytes > priorBytes
388
+ ? [
389
+ `Body grew ${priorBytes} -> ${nextBytes} bytes. An article carries current ` +
390
+ `state; if this growth is narrated history (old values, transitions), move ` +
391
+ `it to the journal and keep the article at what is true now.`,
392
+ ]
393
+ : [];
278
394
  /* What this write put in the window, which is what the agent supplied, not the
279
395
  merged article: a capsule-only write does not deliver the stored body. */
280
396
  surfacer.markUpdated(
281
397
  qualify(article.path),
282
398
  [params.capsule, params.body].filter(Boolean).map(String).join("\n"),
283
399
  );
400
+ const missingAsset = orphaned(mount.dir, article);
284
401
  return [
285
402
  `Wrote ${qualify(article.path)}.`,
286
- ...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }),
403
+ ...growth,
404
+ ...verdict.warnings.map((line) => `schema: ${line}`),
405
+ ...problems,
406
+ ...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }, schema),
407
+ ...(missingAsset ? [missingAsset] : []),
287
408
  ].join("\n");
288
409
  }
289
410
  case "journal": {
@@ -330,7 +451,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
330
451
  case "map":
331
452
  return store.map(path);
332
453
  case "search":
333
- return search(store, String(params.query ?? ""));
454
+ return search(store, String(params.query ?? ""), params.journal === true);
334
455
  default:
335
456
  return `Unknown action "${action}". Actions: read, write, journal, map, search.`;
336
457
  }