@north-light/crouter 0.3.201 → 0.3.202

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.
@@ -2,7 +2,9 @@ import { existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs';
2
2
  import { dirname, resolve as resolvePath } from 'node:path';
3
3
  import { defineLeaf } from '../../core/command.js';
4
4
  import { CrtrError, notFound, usage } from '../../core/errors.js';
5
+ import { readText } from '../../core/fs-utils.js';
5
6
  import { resolveMemoryDoc } from '../../core/memory-resolver.js';
7
+ import { appendHistoryRecord, buildHistoryRecord, historyLogPathFor } from '../../core/memory/history.js';
6
8
  import { MEMORY_SCOPES } from './shared.js';
7
9
  /** Remove the file's now-empty parent directories up to (never including) the
8
10
  * scope's `memory/` root, so deleting the last doc under an `area/` prefix
@@ -35,12 +37,14 @@ export const deleteLeaf = defineLeaf({
35
37
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: node, user, project, or profile.' },
36
38
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path of the file that was removed.' },
37
39
  { name: 'deleted', type: 'boolean', required: true, constraint: 'Always true on success — the file was removed. A missing document fails with not_found instead.' },
38
- { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command — browse what remains.' },
40
+ { name: 'log_path', type: 'string', required: true, constraint: 'Absolute path to the document’s revision log, which OUTLIVES the document — the delete is appended to it as a tombstone carrying the full final text, still readable with `crtr memory history`.' },
41
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands — read the life of the removed doc, or browse what remains.' },
39
42
  ],
40
43
  outputKind: 'object',
41
44
  effects: [
42
45
  'Permanently removes memory/<name>.md from the resolved scope store, and prunes any parent directories it leaves empty. Irreversible.',
43
- ],
46
+ 'Appends a `delete` record carrying the document’s full final text to memory/.history/<name>.jsonl. The log itself is never removed.',
47
+ ]
44
48
  },
45
49
  run: async (input) => {
46
50
  const nameRaw = input['name'];
@@ -64,14 +68,21 @@ export const deleteLeaf = defineLeaf({
64
68
  if (doc.scope === 'builtin') {
65
69
  throw usage(`${doc.name} is a builtin document shipped with the package (read-only) — it cannot be deleted. Override it with a same-named doc at a writable scope instead (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, scope: 'builtin' });
66
70
  }
71
+ const before = readText(doc.path);
72
+ const logPath = historyLogPathFor(doc.root, doc.path);
67
73
  rmSync(doc.path);
68
74
  pruneEmptyParents(resolvePath(doc.path), doc.name.split('/').length);
75
+ // The log is the audit trail and outlives the doc: the tombstone carries the
76
+ // full final text, and a later `write` of the same name keeps appending to
77
+ // this same log — one log per name over its whole life.
78
+ appendHistoryRecord(logPath, buildHistoryRecord({ op: 'delete', before, after: '' }));
69
79
  return {
70
80
  name: doc.name,
71
81
  scope: doc.scope,
72
82
  path: doc.path,
73
83
  deleted: true,
74
- follow_up: `Removed. Browse what remains with \`crtr memory list\`.`,
84
+ log_path: logPath,
85
+ follow_up: `Removed. Its revision history survives — read it with \`crtr memory history ${doc.name}\`, or browse what remains with \`crtr memory list\`.`,
75
86
  };
76
87
  },
77
88
  });
@@ -0,0 +1 @@
1
+ export declare const editLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,166 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { CrtrError, notFound, usage } from '../../core/errors.js';
3
+ import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
4
+ import { readText, writeText } from '../../core/fs-utils.js';
5
+ import { resolveMemoryDoc } from '../../core/memory-resolver.js';
6
+ import { appendHistoryRecord, buildHistoryRecord, historyLogPathFor, readHistoryRecords, } from '../../core/memory/history.js';
7
+ import { DOC_RATIONALE_CONSTRAINT, GUIDE_DOC_LINKS, GUIDE_PREDICATE_VOCABULARY, GUIDE_ROUTING_LINE, GUIDE_VISIBILITY_RUNGS, MEMORY_SCOPES, coerceAppliesTo, coerceGate, coerceReadWhen, overlayParam, literalBodySegment, serializeMemoryDocLiteral, } from './shared.js';
8
+ /** Frontmatter fields an overlay flag cannot express removing. `kind`, the
9
+ * routing line, and the two visibility rungs are the document's contract (a
10
+ * doc without them is malformed); `origin` and `last-updated` are runtime
11
+ * provenance. */
12
+ const CLEARABLE_FIELDS = ['short-form', 'gate', 'applies-to', 'read-when', 'slash', 'rationale'];
13
+ export const editLeaf = defineLeaf({
14
+ name: 'edit',
15
+ description: 'revise an existing memory document, recording why',
16
+ whenToUse: 'a document that already exists needs to change — its body, its frontmatter, or both. Every revision carries a --rationale and is appended to the doc\u2019s revision history, so the corpus keeps a record of why it reached its current state. Resolves the name exactly as `read` does and revises the doc where it lives; use `crtr memory write` only to create one that does not exist yet.',
17
+ help: {
18
+ name: 'memory edit',
19
+ summary: 'resolve an existing document and revise its body and/or frontmatter, appending a history record',
20
+ guide: 'The body you pipe is saved byte for byte. Nothing between stdin and disk parses, normalizes, summarizes, or rewrites it \u2014 the frontmatter fence is prepended and the text is otherwise untouched. Relaying a person\u2019s own words is therefore safe: pass them through unchanged and mark them with --verbatim.\n\n' +
21
+ 'Two different rationales exist and they never share a flag. --rationale is why THIS REVISION is happening; it is required, it lands in the revision history, and it never touches the document text. --doc-rationale replaces the document\u2019s standing `rationale` frontmatter field \u2014 the observed gap the doc exists to close.\n\n' +
22
+ 'Omitting stdin leaves the body exactly as it stands, which is how a frontmatter-only revision is made. Piping a body REPLACES the whole body; there is no partial or append mode. Emptying a document is not an edit \u2014 that intent is `crtr memory delete`.\n\n' +
23
+ 'Omitted frontmatter flags preserve their fields. --unset is the only way to REMOVE one, and it is limited to fields a doc can legitimately live without (short-form, gate, applies-to, read-when, slash, rationale). --applies-to replaces the whole route set rather than adding to it.\n\n' +
24
+ 'An edit that changes nothing fails. A rationale attached to zero change is noise in the trail the history exists to build \u2014 if the doc already says what you meant, there is nothing to record.\n\n' +
25
+ '`last-updated` is stamped on every successful edit; `origin` is preserved, so it keeps pointing at the conversation that created the doc rather than the last one to touch it.\n\n' +
26
+ GUIDE_ROUTING_LINE + '\n\n' +
27
+ GUIDE_VISIBILITY_RUNGS + '\n\n' +
28
+ GUIDE_PREDICATE_VOCABULARY + '\n\n' +
29
+ GUIDE_DOC_LINKS + '\n\n' +
30
+ 'Create a document that does not exist yet with `crtr memory write`. Read the revisions back with `crtr memory history`.',
31
+ params: [
32
+ { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it: exact identity/direct path by precedence node > project stack > profile > user, then bare leaf fallback. A leaf matching multiple documents in one source is rejected as ambiguous \u2014 pass the full name or --scope. Builtin and installed-plugin docs are read-only corpora and are refused.' },
33
+ { kind: 'flag', name: 'rationale', type: 'string', required: true, constraint: 'Why THIS REVISION is happening \u2014 the audit-trail entry, required on every edit. Recorded in the document\u2019s revision history, NEVER written into the document. To change the doc\u2019s standing `rationale` frontmatter field instead, use --doc-rationale.' },
34
+ { kind: 'flag', name: 'verbatim', type: 'bool', required: false, constraint: 'Declares the piped body a verbatim payload from the principal \u2014 their own text, relayed unchanged \u2014 rather than agent-authored prose. Pure provenance recorded in the history record; the save path is identical either way, because there is only one save path and it never transforms.' },
35
+ overlayParam('kind'),
36
+ overlayParam('when-and-why-to-read'),
37
+ overlayParam('short-form'),
38
+ overlayParam('system-prompt-visibility'),
39
+ overlayParam('file-read-visibility'),
40
+ overlayParam('gate'),
41
+ overlayParam('applies-to'),
42
+ overlayParam('read-when'),
43
+ overlayParam('slash', {}, 'Presence SETS the field; remove it with `--unset slash`.'),
44
+ { kind: 'flag', name: 'doc-rationale', type: 'string', required: false, constraint: DOC_RATIONALE_CONSTRAINT },
45
+ { kind: 'flag', name: 'unset', type: 'enum', choices: [...CLEARABLE_FIELDS], required: false, repeatable: true, constraint: 'Remove a frontmatter field an overlay flag cannot express removing. One field per occurrence. `kind`, `when-and-why-to-read`, the two visibility rungs, `origin`, and `last-updated` are never clearable. Unsetting `applies-to` while file-read-visibility is not none fails on the same invariant `write` raises.' },
46
+ { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope before editing. A disambiguation filter only \u2014 never a creation target, and never a way to move a doc between stores.' },
47
+ { kind: 'stdin', name: 'body', required: false, constraint: 'Full replacement body (markdown, no frontmatter), saved byte for byte. Piped on stdin only \u2014 this leaf already claims the one positional for NAME. Absent or empty stdin leaves the existing body untouched, which is how a frontmatter-only revision is made.' },
48
+ ],
49
+ output: [
50
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved document name.' },
51
+ { name: 'kind', type: 'string', required: true, constraint: 'Kind recorded in frontmatter after the revision.' },
52
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was revised in: node, project, profile, or user.' },
53
+ { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the revised document.' },
54
+ { name: 'revision', type: 'number', required: true, constraint: 'This document\u2019s record count after the append \u2014 the revision number to pass to `crtr memory history --revision`.' },
55
+ { name: 'changed', type: 'string[]', required: true, constraint: '`body` when the body text changed, plus the frontmatter field names whose values changed. `last-updated` is excluded \u2014 it is stamped on every edit, so naming it says nothing.' },
56
+ { name: 'log_path', type: 'string', required: true, constraint: 'Absolute path to the document\u2019s append-only revision log (JSONL, one self-contained record per line).' },
57
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands \u2014 read the revisions or read the doc back.' },
58
+ ],
59
+ outputKind: 'object',
60
+ effects: [
61
+ 'Rewrites memory/<name>.md at the resolved scope and appends one `edit` record to memory/.history/<name>.jsonl.',
62
+ ],
63
+ },
64
+ run: async (input) => {
65
+ const nameRaw = input['name'];
66
+ const scopeArg = input['scope'];
67
+ const rationale = input['rationale'];
68
+ const verbatim = input['verbatim'] === true;
69
+ const stdinBody = input['body'] ?? '';
70
+ let doc;
71
+ try {
72
+ doc = resolveMemoryDoc(nameRaw, {
73
+ includeDescendants: true,
74
+ ...(scopeArg !== undefined ? { scope: scopeArg } : {}),
75
+ });
76
+ }
77
+ catch (e) {
78
+ if (e instanceof CrtrError && e.code === 'not_found') {
79
+ throw notFound(`memory document not found: ${nameRaw}`, {
80
+ memory: nameRaw,
81
+ next: `\`edit\` revises a document that already exists. Create it with \`crtr memory write ${nameRaw} ...\`, or run \`crtr memory find <query>\` if it exists under another name.`,
82
+ });
83
+ }
84
+ throw e; // ambiguous / usage propagate with their own recovery guidance
85
+ }
86
+ if (doc.plugin !== undefined) {
87
+ throw usage(`${doc.name} belongs to the installed plugin "${doc.plugin}" \u2014 a read-only corpus managed by \`crtr pkg\`, not editable here. To override just this doc, write a same-named doc at a writable scope (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, plugin: doc.plugin });
88
+ }
89
+ if (doc.scope === 'builtin') {
90
+ throw usage(`${doc.name} is a builtin document shipped with the package (read-only) \u2014 it cannot be edited. Override it with a same-named doc at a writable scope instead (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, scope: 'builtin' });
91
+ }
92
+ const before = readText(doc.path);
93
+ const parsed = parseFrontmatterGeneric(before);
94
+ const previous = { ...(parsed.data ?? {}) };
95
+ const frontmatter = { ...previous };
96
+ const setIf = (key, value) => {
97
+ if (value !== undefined)
98
+ frontmatter[key] = value;
99
+ };
100
+ setIf('kind', input['kind']);
101
+ setIf('when-and-why-to-read', input['whenAndWhyToRead']);
102
+ setIf('short-form', input['shortForm']);
103
+ setIf('system-prompt-visibility', input['systemPromptVisibility']);
104
+ setIf('file-read-visibility', input['fileReadVisibility']);
105
+ if (input['gate'] !== undefined)
106
+ frontmatter['gate'] = coerceGate(input['gate']);
107
+ if (input['appliesTo'] !== undefined)
108
+ frontmatter['applies-to'] = coerceAppliesTo(input['appliesTo']);
109
+ if (input['readWhen'] !== undefined)
110
+ frontmatter['read-when'] = coerceReadWhen(input['readWhen']);
111
+ if (input['slash'] === true)
112
+ frontmatter['slash'] = true;
113
+ setIf('rationale', input['docRationale']);
114
+ for (const field of input['unset'] ?? []) {
115
+ delete frontmatter[field];
116
+ }
117
+ const applies = frontmatter['applies-to'];
118
+ const routes = typeof applies === 'string'
119
+ ? [applies]
120
+ : Array.isArray(applies) && applies.every((value) => typeof value === 'string')
121
+ ? applies
122
+ : [];
123
+ if (frontmatter['file-read-visibility'] !== 'none' && routes.length === 0) {
124
+ throw usage(`file-read-visibility ${String(frontmatter['file-read-visibility'])} requires at least one applies-to route: pass --applies-to (use "." for project workspace/profile-open context, a real file glob for matching reads), or set --file-read-visibility none`);
125
+ }
126
+ if (doc.scope !== 'project' && routes.some((route) => route.trim() === '.')) {
127
+ throw usage('applies-to "." is a project-store workspace mount route; use a real file glob or choose file-read-visibility none for user, profile, and node memory');
128
+ }
129
+ // LITERAL SAVE: the piped bytes reach disk untransformed, one structural
130
+ // blank line after the fence. Omitting stdin re-emits the existing body
131
+ // sliced from the raw file — never the parser's body, which drops leading
132
+ // blank lines and indentation.
133
+ const existingBody = literalBodySegment(before);
134
+ const bodySegment = stdinBody === '' ? existingBody : `\n${stdinBody}`;
135
+ // The no-op check compares against the document as it would serialize
136
+ // WITHOUT a fresh timestamp — `last-updated` changes on every edit, so
137
+ // stamping first would make every no-op look like a change.
138
+ const candidate = serializeMemoryDocLiteral(frontmatter, bodySegment);
139
+ if (candidate === before) {
140
+ throw usage(`edit changed nothing \u2014 ${doc.name} already says exactly this, so there is nothing to record`, {
141
+ memory: doc.name,
142
+ next: 'Pipe a different body or pass a frontmatter flag that changes a value. To remove the doc entirely, use `crtr memory delete`.',
143
+ });
144
+ }
145
+ frontmatter['last-updated'] = new Date().toISOString();
146
+ const after = serializeMemoryDocLiteral(frontmatter, bodySegment);
147
+ writeText(doc.path, after);
148
+ const logPath = historyLogPathFor(doc.root, doc.path);
149
+ appendHistoryRecord(logPath, buildHistoryRecord({ op: 'edit', rationale, ...(verbatim ? { verbatim: true } : {}), before, after }));
150
+ const changedFields = [...new Set([...Object.keys(previous), ...Object.keys(frontmatter)])]
151
+ .filter((key) => key !== 'last-updated')
152
+ .filter((key) => JSON.stringify(previous[key]) !== JSON.stringify(frontmatter[key]))
153
+ .sort((a, b) => a.localeCompare(b));
154
+ const changed = [...(bodySegment !== existingBody ? ['body'] : []), ...changedFields];
155
+ return {
156
+ name: doc.name,
157
+ kind: String(frontmatter['kind'] ?? ''),
158
+ scope: doc.scope,
159
+ path: doc.path,
160
+ revision: readHistoryRecords(logPath).length,
161
+ changed,
162
+ log_path: logPath,
163
+ follow_up: `Recorded. Review the revisions with \`crtr memory history ${doc.name} --diff\`, or read the doc back with \`crtr memory read ${doc.name}\`.`,
164
+ };
165
+ },
166
+ });
@@ -0,0 +1 @@
1
+ export declare const historyLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,179 @@
1
+ import { join } from 'node:path';
2
+ import { defineLeaf } from '../../core/command.js';
3
+ import { CrtrError, notFound, usage } from '../../core/errors.js';
4
+ import { nativeMemoryStoresInPrecedence, resolveHistoryLogPath, resolveMemoryDoc } from '../../core/memory-resolver.js';
5
+ import { renderResult } from '../../core/render.js';
6
+ import { parseSkillQualifier } from '../../core/resolver.js';
7
+ import { HISTORY_DIR, historyLogPathFor, readHistoryRecords, summarizeChange, unifiedDiff, } from '../../core/memory/history.js';
8
+ import { MEMORY_SCOPES } from './shared.js';
9
+ /** Locate a document's revision log. A live doc resolves through the normal
10
+ * document precedence walk; a DELETED one has no file to resolve, so its log
11
+ * is resolved by name against each native store's `.history` tree in the same
12
+ * order, under the same normalization the doc had — history outlives the
13
+ * document it describes, answering to the name that document answered to. */
14
+ function resolveLog(nameRaw, scopeArg) {
15
+ let doc;
16
+ try {
17
+ doc = resolveMemoryDoc(nameRaw, {
18
+ includeDescendants: true,
19
+ ...(scopeArg !== undefined ? { scope: scopeArg } : {}),
20
+ });
21
+ }
22
+ catch (e) {
23
+ if (!(e instanceof CrtrError && e.code === 'not_found'))
24
+ throw e;
25
+ }
26
+ if (doc !== undefined) {
27
+ if (doc.plugin !== undefined) {
28
+ throw usage(`${doc.name} belongs to the installed plugin "${doc.plugin}" — a read-only corpus with no revision history.`, {
29
+ memory: doc.name,
30
+ plugin: doc.plugin,
31
+ });
32
+ }
33
+ if (doc.scope === 'builtin') {
34
+ throw usage(`${doc.name} is a builtin document shipped with the package — a read-only corpus with no revision history.`, {
35
+ memory: doc.name,
36
+ scope: 'builtin',
37
+ });
38
+ }
39
+ return { name: doc.name, scope: doc.scope, logPath: historyLogPathFor(doc.root, doc.path) };
40
+ }
41
+ const parsed = parseSkillQualifier(nameRaw);
42
+ const name = parsed.segments.join('/');
43
+ if (name === '')
44
+ throw usage('memory document name required');
45
+ const scope = scopeArg ?? parsed.scope;
46
+ for (const store of nativeMemoryStoresInPrecedence(scope, true)) {
47
+ const logPath = resolveHistoryLogPath(join(store.memoryDir, HISTORY_DIR), parsed.segments);
48
+ if (logPath !== null)
49
+ return { name, scope: store.scope, logPath };
50
+ }
51
+ throw notFound(`no memory document or revision history found: ${nameRaw}`, {
52
+ memory: nameRaw,
53
+ next: 'Run `crtr memory find <query>` to locate the document. A document that exists but has no history predates this surface or has never been mutated through it.',
54
+ });
55
+ }
56
+ export const historyLeaf = defineLeaf({
57
+ name: 'history',
58
+ description: 'read a memory document\u2019s revision history',
59
+ whenToUse: 'you want to know how a document reached its current state \u2014 who revised it, when, why, and what changed per revision. Works for a deleted document too: the log outlives the doc. A diagnostics read; it advances nothing and changes nothing.',
60
+ help: {
61
+ name: 'memory history',
62
+ summary: 'list a document\u2019s revisions newest-first, with diffs or one full record on demand',
63
+ guide: 'Every mutation through `crtr memory write`, `edit`, and `delete` appends one self-contained record \u2014 complete document text before and after, plus who, when, and (on an edit) why. Diffs and revision numbers are derived here at read time, never stored.\n\n' +
64
+ 'A record whose `before` differs from the previous record\u2019s `after` is the evidence of an out-of-band direct file edit; nothing prevents those, and nothing detects them beyond this.\n\n' +
65
+ 'The log is a plain JSONL file at `log_path`, one JSON object per line \u2014 an external consumer reads it directly rather than through this command.',
66
+ params: [
67
+ { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it. When no such document exists, the same resolution runs against each writable store\u2019s history tree in precedence order \u2014 which is how a deleted document\u2019s history still answers to the name that document answered to. Builtin and installed-plugin docs are read-only corpora and carry no history.' },
68
+ { kind: 'flag', name: 'limit', type: 'int', required: false, default: 10, constraint: 'How many revisions to return, newest first. Default 10.' },
69
+ { kind: 'flag', name: 'diff', type: 'bool', required: false, constraint: 'Include a unified diff per listed revision, computed from that record\u2019s own before/after texts.' },
70
+ { kind: 'flag', name: 'revision', type: 'int', required: false, constraint: 'Show ONE revision in full \u2014 its metadata plus the complete before and after document texts. 1-based position in the log: the number `crtr memory edit` reports. Overrides --limit and --diff.' },
71
+ { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope. A disambiguation filter for a name present at several scopes.' },
72
+ ],
73
+ output: [
74
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved document name.' },
75
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope whose store holds the log: node, project, profile, or user.' },
76
+ { name: 'log_path', type: 'string', required: true, constraint: 'Absolute path to the append-only JSONL log \u2014 the direct-consumption pointer, one self-contained record per line.' },
77
+ { name: 'total', type: 'number', required: true, constraint: 'Total revisions in the log.' },
78
+ { name: 'revisions', type: 'object[]', required: false, constraint: 'Newest-first, capped by --limit. Each: {revision, op, at, node, rationale, body_changed, frontmatter_changed, diff}. `node` and `rationale` are present only when recorded; `diff` only with --diff. Absent when --revision selects one record.' },
79
+ { name: 'record', type: 'object', required: false, constraint: 'Present only with --revision: {revision, op, at, node, cwd, rationale, verbatim, before, after} \u2014 the full record including both complete document texts.' },
80
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands \u2014 the flag variants and the doc read.' },
81
+ ],
82
+ outputKind: 'object',
83
+ effects: ['None. Read-only.'],
84
+ },
85
+ // A unified diff is multi-line, and the default object[] renderer flattens it
86
+ // into one table cell. With --diff each revision gets its own block instead;
87
+ // every other shape falls through to the default rendering.
88
+ render: (result) => {
89
+ const revisions = result['revisions'];
90
+ if (revisions === undefined || !revisions.some((r) => typeof r['diff'] === 'string')) {
91
+ return renderResult(result, historyLeaf.help);
92
+ }
93
+ const blocks = revisions.map((r) => {
94
+ const meta = [`op: ${String(r['op'])}`, `at: ${String(r['at'])}`];
95
+ if (r['node'] !== undefined)
96
+ meta.push(`node: ${String(r['node'])}`);
97
+ if (r['verbatim'] === true)
98
+ meta.push('verbatim');
99
+ const fields = r['frontmatter_changed'] ?? [];
100
+ const changed = [
101
+ ...(r['body_changed'] === true ? ['body'] : []),
102
+ ...(fields.length > 0 ? [`frontmatter: ${fields.join(', ')}`] : []),
103
+ ];
104
+ const diff = String(r['diff'] ?? '');
105
+ return [
106
+ `## revision ${String(r['revision'])} \u2014 ${meta.join(' \u00b7 ')}`,
107
+ ...(r['rationale'] !== undefined ? [`rationale: ${String(r['rationale'])}`] : []),
108
+ `changed: ${changed.length > 0 ? changed.join('; ') : 'nothing'}`,
109
+ ...(diff === '' ? [] : ['', '```diff', diff, '```']),
110
+ ].join('\n');
111
+ });
112
+ return [
113
+ `- name: ${String(result['name'])}`,
114
+ `- scope: ${String(result['scope'])}`,
115
+ `- log_path: ${String(result['log_path'])}`,
116
+ `- total: ${String(result['total'])}`,
117
+ '',
118
+ blocks.join('\n\n'),
119
+ '',
120
+ String(result['follow_up']),
121
+ ].join('\n');
122
+ },
123
+ run: async (input) => {
124
+ const nameRaw = input['name'];
125
+ const scopeArg = input['scope'];
126
+ const limit = input['limit'] ?? 10;
127
+ const wantDiff = input['diff'] === true;
128
+ const revisionArg = input['revision'];
129
+ const { name, scope, logPath } = resolveLog(nameRaw, scopeArg);
130
+ const records = readHistoryRecords(logPath);
131
+ if (records.length === 0) {
132
+ throw notFound(`no revision history recorded for ${name}`, {
133
+ memory: name,
134
+ next: 'The document predates this surface or has never been mutated through `crtr memory write`/`edit`/`delete`. Its first edit records the pre-existing content in full.',
135
+ });
136
+ }
137
+ if (revisionArg !== undefined) {
138
+ if (revisionArg < 1 || revisionArg > records.length) {
139
+ throw usage(`revision ${revisionArg} is out of range for ${name} (1..${records.length})`, {
140
+ memory: name,
141
+ next: `Run \`crtr memory history ${name}\` to see which revisions exist.`,
142
+ });
143
+ }
144
+ const r = records[revisionArg - 1];
145
+ return {
146
+ name,
147
+ scope,
148
+ log_path: logPath,
149
+ total: records.length,
150
+ record: { revision: revisionArg, ...r },
151
+ follow_up: `Read the document as it stands with \`crtr memory read ${name}\`, or list the revisions with \`crtr memory history ${name} --diff\`.`,
152
+ };
153
+ }
154
+ const revisions = records
155
+ .map((r, i) => {
156
+ const summary = summarizeChange(r.before, r.after);
157
+ return {
158
+ revision: i + 1,
159
+ op: r.op,
160
+ at: r.at,
161
+ ...(r.node !== undefined ? { node: r.node } : {}),
162
+ ...(r.rationale !== undefined ? { rationale: r.rationale } : {}),
163
+ ...(r.verbatim === true ? { verbatim: true } : {}),
164
+ ...summary,
165
+ ...(wantDiff ? { diff: unifiedDiff(r.before, r.after) } : {}),
166
+ };
167
+ })
168
+ .reverse()
169
+ .slice(0, Math.max(0, limit));
170
+ return {
171
+ name,
172
+ scope,
173
+ log_path: logPath,
174
+ total: records.length,
175
+ revisions,
176
+ follow_up: `Add --diff for per-revision unified diffs, --revision N for one record's full before/after text, or read the document as it stands with \`crtr memory read ${name}\`.`,
177
+ };
178
+ },
179
+ });
@@ -86,7 +86,7 @@ export const listLeaf = defineLeaf({
86
86
  })),
87
87
  next_cursor: result.next_cursor,
88
88
  total: result.total,
89
- follow_up: 'Read one in full with `crtr memory read <name>`, or pass --paths for its file path for a small body-only edit (routing/frontmatter/visibility changes go through `crtr memory write`). Narrow with --kind / --scope, page with --cursor, or search a topic with `crtr memory find <query>`.',
89
+ follow_up: 'Read one in full with `crtr memory read <name>`, or revise one with `crtr memory edit <name> --rationale "<why>"` (body, routing, frontmatter, and visibility all go through that one verb, and it records the change). Narrow with --kind / --scope, page with --cursor, or search a topic with `crtr memory find <query>`.',
90
90
  };
91
91
  },
92
92
  });
@@ -127,7 +127,7 @@ export const readLeaf = defineLeaf({
127
127
  { name: 'name', type: 'string', required: true, constraint: 'Resolved document name.' },
128
128
  { name: 'kind', type: 'string', required: true, constraint: 'Resolved kind: knowledge or preference.' },
129
129
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was resolved from: node, project, profile, user, or builtin.' },
130
- { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the document on disk — direct edits here are for a small body-only tweak; any routing/frontmatter/visibility change goes through `crtr memory write`.' },
130
+ { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the document on disk. Revise it with `crtr memory edit`, never by editing this file — an edit records why the change happened and lands in the doc’s revision history.' },
131
131
  { name: 'content', type: 'string', required: true, constraint: 'Document body. Frontmatter stripped unless --frontmatter is set. A document may embed shell as `!`cmd`` or a ```! fenced block; each runs once per read, in the current working directory, and is replaced by its output. Read `path` off disk when you need the literal unexecuted text.' },
132
132
  { name: 'links', type: 'string[]', required: false, constraint: 'Canonical names this document links to via `[[name]]` that resolve in the current corpus — further reading, loaded only on demand with `crtr memory read <name>`. Omitted when the body carries no resolvable links, and subsumed by `routes` on a directory-INDEX read.' },
133
133
  { name: 'routes', type: 'string[]', required: false, constraint: 'Present only when the document is a directory INDEX: one routing line per document the INDEX gates — the directory’s members plus the body’s resolvable links, a nested INDEX standing in for its subtree with a single line. Follow one with `crtr memory read <name>` when the task in front of you matches its line.' },
@@ -196,7 +196,7 @@ export const readLeaf = defineLeaf({
196
196
  : links.length > 0
197
197
  ? 'The `[[name]]` links in the body are further reading — follow one with `crtr memory read <name>` only when the task needs that depth. '
198
198
  : '') +
199
- 'Use --frontmatter on this same command to inspect the YAML frontmatter, or edit `path` directly for a body-only tweak. Browse the inventory with `crtr memory list`.',
199
+ 'Use --frontmatter on this same command to inspect the YAML frontmatter, or revise the doc with `crtr memory edit` (recorded, with a rationale). Browse the inventory with `crtr memory list`.',
200
200
  };
201
201
  }
202
202
  throw notFound(`memory document not found: ${nameRaw}`, {
@@ -1,3 +1,4 @@
1
+ import type { FlagParam } from '../../core/help.js';
1
2
  import type { MemoryScope } from '../../core/memory-resolver.js';
2
3
  export declare const MEMORY_KINDS: readonly ["knowledge", "preference"];
3
4
  export declare const VISIBILITY_RUNGS: readonly ["none", "name", "preview", "content"];
@@ -52,3 +53,29 @@ export declare function buildOrigin(): Record<string, unknown>;
52
53
  * package — the same one the parser uses — so nested gate maps and applies-to
53
54
  * arrays round-trip), in canonical field order with preserved extras last. */
54
55
  export declare function serializeMemoryDoc(frontmatter: Record<string, unknown>, body: string): string;
56
+ /** Everything after the closing frontmatter fence, byte for byte — leading blank
57
+ * lines and indentation intact. Source without a fence is all body. */
58
+ export declare function literalBodySegment(source: string): string;
59
+ /** Serialize a document WITHOUT touching the body. `bodySegment` is everything
60
+ * after the closing frontmatter fence (leading blank line included), emitted
61
+ * byte for byte. This is the revision path: `edit` saves what it was given, so
62
+ * an unchanged doc round-trips identically and a supplied body reaches disk
63
+ * untransformed. Creation uses `serializeMemoryDoc`, which normalizes a first
64
+ * body into the canonical shape. */
65
+ export declare function serializeMemoryDocLiteral(frontmatter: Record<string, unknown>, bodySegment: string): string;
66
+ /** The frontmatter-overlay flags, keyed by flag name, carrying the prose true
67
+ * on BOTH leaves. `write` appends its creation clauses with `withConstraint`;
68
+ * neither leaf restates the other's rules. */
69
+ export declare const FRONTMATTER_OVERLAY_PARAMS: Record<string, FlagParam>;
70
+ /** One overlay flag, with leaf-specific prose appended to the shared
71
+ * constraint and any leaf-specific schema override applied. */
72
+ export declare function overlayParam(name: string, overrides?: Partial<FlagParam>, extraConstraint?: string): FlagParam;
73
+ /** The doc's standing `rationale` frontmatter field \u2014 the observed gap the doc
74
+ * exists to close. `write` takes it as `--rationale`; `edit` takes it as
75
+ * `--doc-rationale`, because `--rationale` there means why THIS REVISION is
76
+ * happening. Same field, same prose, two flag names that cannot be confused. */
77
+ export declare const DOC_RATIONALE_CONSTRAINT = "Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.";
78
+ export declare const GUIDE_VISIBILITY_RUNGS = "The visibility rungs `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc fits in a single sentence \u2014 a one-line preference or a one-sentence knowledge fact \u2014 skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.";
79
+ export declare const GUIDE_ROUTING_LINE = "The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: \"because only genuine first principles belong in taste memory.\" Bad: \"because keeping the test loop fast and free of speculative tests protects the development pace\" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: \"because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation.\" Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.";
80
+ export declare const GUIDE_PREDICATE_VOCABULARY = "Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.";
81
+ export declare const GUIDE_DOC_LINKS = "Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes (a directory INDEX is linked by its bare directory name). Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias or label form.";
@@ -185,6 +185,7 @@ const FRONTMATTER_ORDER = [
185
185
  'applies-to',
186
186
  'read-when',
187
187
  'rationale',
188
+ 'last-updated',
188
189
  'origin',
189
190
  ];
190
191
  /** Provenance for a doc at the moment it is created: when, where, and which
@@ -223,3 +224,71 @@ export function serializeMemoryDoc(frontmatter, body) {
223
224
  const cleanBody = body.replace(/^\n+/, '').replace(/\s+$/, '');
224
225
  return `---\n${yamlText}\n---\n\n${cleanBody}\n`;
225
226
  }
227
+ // Structure only: the closing fence tolerates trailing spaces/tabs but never
228
+ // consumes the newlines or indentation that open the body. `parseFrontmatterGeneric`
229
+ // closes on `\s*`, which eats them — fine for reading a doc, lossy for rewriting one.
230
+ const LITERAL_FENCE_RE = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
231
+ /** Everything after the closing frontmatter fence, byte for byte — leading blank
232
+ * lines and indentation intact. Source without a fence is all body. */
233
+ export function literalBodySegment(source) {
234
+ const match = source.match(LITERAL_FENCE_RE);
235
+ return match ? source.slice(match[0].length) : source;
236
+ }
237
+ /** Serialize a document WITHOUT touching the body. `bodySegment` is everything
238
+ * after the closing frontmatter fence (leading blank line included), emitted
239
+ * byte for byte. This is the revision path: `edit` saves what it was given, so
240
+ * an unchanged doc round-trips identically and a supplied body reaches disk
241
+ * untransformed. Creation uses `serializeMemoryDoc`, which normalizes a first
242
+ * body into the canonical shape. */
243
+ export function serializeMemoryDocLiteral(frontmatter, bodySegment) {
244
+ const ordered = {};
245
+ for (const key of FRONTMATTER_ORDER) {
246
+ if (frontmatter[key] !== undefined)
247
+ ordered[key] = frontmatter[key];
248
+ }
249
+ for (const key of Object.keys(frontmatter)) {
250
+ if (!(key in ordered) && frontmatter[key] !== undefined)
251
+ ordered[key] = frontmatter[key];
252
+ }
253
+ const yamlText = yamlStringify(ordered).replace(/\n+$/, '');
254
+ return `---\n${yamlText}\n---\n${bodySegment}`;
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // Help material shared by `write` and `edit`
258
+ //
259
+ // Single-sourced: both leaves compose these, so a wording fix lands on both.
260
+ // Anything exclusive to one leaf (creation rules on `write`, the rationale
261
+ // contract on `edit`) stays owned by that leaf.
262
+ // ---------------------------------------------------------------------------
263
+ /** The frontmatter-overlay flags, keyed by flag name, carrying the prose true
264
+ * on BOTH leaves. `write` appends its creation clauses with `withConstraint`;
265
+ * neither leaf restates the other's rules. */
266
+ export const FRONTMATTER_OVERLAY_PARAMS = {
267
+ 'kind': { kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: false, constraint: 'Document kind.' },
268
+ 'when-and-why-to-read': { kind: 'flag', name: 'when-and-why-to-read', type: 'string', required: false, constraint: 'ONE routing sentence: "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the reader\u2019s payoff \u2014 the consequence they secure for their task by reading \u2014 NEVER the doc summary, its rule, or that rule reworded as an outcome (a benefit-shaped restatement still fails). Rendered verbatim as the preview.' },
269
+ 'short-form': { kind: 'flag', name: 'short-form', type: 'string', required: false, constraint: 'Frontmatter short-form \u2014 a very abbreviated version of the content, the hook shown in `crtr memory list`.' },
270
+ 'system-prompt-visibility': { kind: 'flag', name: 'system-prompt-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much of this document auto-loads into the system prompt / CLI help.' },
271
+ 'file-read-visibility': { kind: 'flag', name: 'file-read-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much surfaces through file context. Every value except none also requires at least one applies-to route in the resulting document.' },
272
+ 'gate': { kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate \u2014 YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
273
+ 'applies-to': { kind: 'flag', name: 'applies-to', type: 'string', required: false, repeatable: true, constraint: 'One explicit file-context route per occurrence; the flag set replaces the document\u2019s whole route list. In a project store, `.` fires when cwd/profile mounts that workspace during first-message assembly AND on any file read beneath the store\u2019s owning dir. Any other value is a glob matched after an actual file read against the absolute path, basename, and path relative to the owning project root. There is no positional fallback from where the memory file lives. Required whenever file-read-visibility is not none.' },
274
+ 'read-when': { kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when \u2014 YAML/JSON object predicate over a read file\u2019s own frontmatter using the same field/matcher vocabulary described in the guide.' },
275
+ 'slash': { kind: 'flag', name: 'slash', type: 'bool', required: false, default: false, constraint: 'Presence flags this doc invocable as a pi slash command (`/<name>`, `/` in a nested name rendered as `:`) \u2014 the doc body becomes the command\u2019s injected prompt. Default false: most docs are consulted, not invoked.' },
276
+ };
277
+ /** One overlay flag, with leaf-specific prose appended to the shared
278
+ * constraint and any leaf-specific schema override applied. */
279
+ export function overlayParam(name, overrides = {}, extraConstraint) {
280
+ const base = FRONTMATTER_OVERLAY_PARAMS[name];
281
+ if (base === undefined)
282
+ throw new Error(`unknown frontmatter overlay param: ${name}`);
283
+ const constraint = extraConstraint === undefined ? base.constraint : `${base.constraint} ${extraConstraint}`;
284
+ return { ...base, ...overrides, constraint };
285
+ }
286
+ /** The doc's standing `rationale` frontmatter field \u2014 the observed gap the doc
287
+ * exists to close. `write` takes it as `--rationale`; `edit` takes it as
288
+ * `--doc-rationale`, because `--rationale` there means why THIS REVISION is
289
+ * happening. Same field, same prose, two flag names that cannot be confused. */
290
+ export const DOC_RATIONALE_CONSTRAINT = 'Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.';
291
+ export const GUIDE_VISIBILITY_RUNGS = 'The visibility rungs `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc fits in a single sentence \u2014 a one-line preference or a one-sentence knowledge fact \u2014 skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.';
292
+ export const GUIDE_ROUTING_LINE = 'The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: "because only genuine first principles belong in taste memory." Bad: "because keeping the test loop fast and free of speculative tests protects the development pace" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: "because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation." Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.';
293
+ export const GUIDE_PREDICATE_VOCABULARY = 'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.';
294
+ export const GUIDE_DOC_LINKS = 'Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes (a directory INDEX is linked by its bare directory name). Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias or label form.';