@north-light/crouter 0.3.292 → 0.3.294

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.
@@ -94,3 +94,29 @@ test('memory write and edit set, validate, and unset lint-ignore', async () => {
94
94
  assert.ok(error instanceof CrtrError);
95
95
  assert.equal(error.code, 'usage');
96
96
  });
97
+ test('memory edit --unset removes a legacy frontmatter field and refuses a protected one', async () => {
98
+ writeMemory('legacy.md', 'kind: knowledge\nname: legacy\ndescription: legacy attribution to strip\nwhen-and-why-to-read: When testing legacy field removal, this knowledge should be read because the doc exists to prove removal works.');
99
+ await editLeaf.run({
100
+ name: 'legacy',
101
+ scope: 'user',
102
+ rationale: 'drop the legacy description field',
103
+ unset: ['description'],
104
+ });
105
+ const afterUnset = parseFrontmatterGeneric(readFileSync(join(home, '.crouter', 'memory', 'legacy.md'), 'utf8')).data;
106
+ assert.equal(afterUnset?.['description'], undefined);
107
+ assert.equal(afterUnset?.['name'], 'legacy');
108
+ let error;
109
+ try {
110
+ await editLeaf.run({
111
+ name: 'legacy',
112
+ scope: 'user',
113
+ rationale: 'try to drop a protected field',
114
+ unset: ['when-and-why-to-read'],
115
+ });
116
+ }
117
+ catch (caught) {
118
+ error = caught;
119
+ }
120
+ assert.ok(error instanceof CrtrError);
121
+ assert.equal(error.code, 'usage');
122
+ });
@@ -5,10 +5,14 @@ import { readText, writeText } from '../../core/fs-utils.js';
5
5
  import { loadMemoryStoreView, resolveMemoryCandidates, } from '../../core/memory-resolver.js';
6
6
  import { appendHistoryRecord, buildHistoryRecord, historyLogPathFor, readHistoryRecords, } from '../../core/memory/history.js';
7
7
  import { DOC_RATIONALE_CONSTRAINT, GUIDE_CANONICAL_NAMES, GUIDE_DOC_LINKS, GUIDE_PREDICATE_VOCABULARY, GUIDE_ROUTING_LINE, GUIDE_SURFACES, coerceGate, coerceLintIgnore, coerceSurface, memoryExtensionCatalogForDoc, memoryExtensionFieldCatalogHelp, overlayParam, parseRequestedExtensionChanges, applyExtensionChanges, literalBodySegment, serializeMemoryDocLiteral, selectorParam, requireLocalNameInStore, requireUnambiguousDoc, resolveReadSelector, } from './shared.js';
8
- /** Frontmatter fields an overlay flag cannot express removing. `kind` and the
9
- * routing line are the document's contract (a doc without them is malformed);
10
- * `origin` and `last-updated` are runtime provenance. */
8
+ /** Frontmatter fields an overlay flag cannot express removing. Accepted for
9
+ * `--unset` unconditionally — whether or not the target doc currently
10
+ * carries them. */
11
11
  const CLEARABLE_FIELDS = ['short-form', 'gate', 'surfaces', 'unlisted', 'slash', 'rationale', 'lint-ignore'];
12
+ /** `kind` and the routing line are the document's contract (a doc without
13
+ * them is malformed); `origin` and `last-updated` are runtime provenance.
14
+ * Never removable via `--unset`, regardless of what the doc carries. */
15
+ const PROTECTED_FIELDS = ['kind', 'when-and-why-to-read', 'origin', 'last-updated'];
12
16
  export const editLeaf = defineLeaf({
13
17
  name: 'edit',
14
18
  description: 'revise an existing memory document, recording why',
@@ -19,7 +23,7 @@ export const editLeaf = defineLeaf({
19
23
  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' +
20
24
  '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' +
21
25
  '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' +
22
- '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, surfaces, unlisted, slash, rationale, lint-ignore). --surface replaces the whole surfaces list rather than adding to it.\n\n' +
26
+ 'Omitted frontmatter flags preserve their fields. --unset is the only way to REMOVE one. The canonical clearable set (short-form, gate, surfaces, unlisted, slash, rationale, lint-ignore) is always accepted; any other field name is accepted only when the document’s frontmatter already carries it — the path for dropping a legacy or non-canonical field. --surface replaces the whole surfaces list rather than adding to it.\n\n' +
23
27
  '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' +
24
28
  '`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' +
25
29
  GUIDE_ROUTING_LINE + '\n\n' +
@@ -43,7 +47,7 @@ export const editLeaf = defineLeaf({
43
47
  { kind: 'flag', name: 'doc-rationale', type: 'string', required: false, constraint: DOC_RATIONALE_CONSTRAINT },
44
48
  { kind: 'flag', name: 'extension', type: 'string', required: false, repeatable: true, constraint: 'Set one declared plugin field as `extensions.<plugin>.<field>=VALUE`. The path is required in full; booleans accept only true or false, numbers require finite numeric syntax, and strings/enums preserve the literal text after the first =. Every requested field validates before the document is revised.' },
45
49
  { kind: 'flag', name: 'unset-extension', type: 'string', required: false, repeatable: true, constraint: 'Remove one explicitly persisted declared field by its full `extensions.<plugin>.<field>` path. A removed explicit value lets its declaration default apply only in structured output.' },
46
- { 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`, `origin`, and `last-updated` are never clearable.' },
50
+ { kind: 'flag', name: 'unset', type: 'string', required: false, repeatable: true, constraint: `Remove a frontmatter field. One field per occurrence. ${CLEARABLE_FIELDS.join(', ')} are always accepted, whether or not the doc currently carries them. Any other field name is accepted only when the target document's frontmatter already carries it — this is the path for dropping a legacy or otherwise non-canonical field (e.g. a stale \`name:\` or \`description:\` left over from before \`when-and-why-to-read\`). Requesting a field the doc does not have fails, naming the fields it does have. \`${PROTECTED_FIELDS.join('\`, \`')}\` are never clearable.` },
47
51
  selectorParam('scope-read', {}, 'Edit resolves the address winner and refuses same-store physical collisions.'),
48
52
  selectorParam('dir'),
49
53
  selectorParam('profile'),
@@ -154,6 +158,21 @@ export const editLeaf = defineLeaf({
154
158
  setIf('rationale', input['docRationale']);
155
159
  applyExtensionChanges(frontmatter, extensionChanges.sets, extensionChanges.unsets);
156
160
  for (const field of input['unset'] ?? []) {
161
+ if (PROTECTED_FIELDS.includes(field)) {
162
+ throw usage(`--unset ${field}: ${field} is never clearable`, {
163
+ memory: doc.name,
164
+ next: `Protected fields are ${PROTECTED_FIELDS.join(', ')}.`,
165
+ });
166
+ }
167
+ if (!CLEARABLE_FIELDS.includes(field) && !Object.prototype.hasOwnProperty.call(previous, field)) {
168
+ const present = Object.keys(previous).filter((key) => !PROTECTED_FIELDS.includes(key));
169
+ throw usage(`--unset ${field}: ${doc.name} has no \`${field}\` field to unset`, {
170
+ memory: doc.name,
171
+ next: present.length > 0
172
+ ? `Fields ${doc.name} has: ${present.join(', ')}.`
173
+ : `${doc.name} carries no field eligible for --unset.`,
174
+ });
175
+ }
157
176
  delete frontmatter[field];
158
177
  }
159
178
  // Same invariant `write` raises: a workspace-open entry fires only when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.292",
3
+ "version": "0.3.294",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.292",
3
+ "version": "0.3.294",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.292",
9
+ "version": "0.3.294",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -4244,17 +4244,17 @@
4244
4244
  },
4245
4245
  "packages/crouter-api": {
4246
4246
  "name": "@north-light/crouter-api",
4247
- "version": "0.3.292",
4247
+ "version": "0.3.294",
4248
4248
  "license": "UNLICENSED"
4249
4249
  },
4250
4250
  "packages/crouter-env-docker": {
4251
4251
  "name": "@north-light/crouter-env-docker",
4252
- "version": "0.3.292",
4252
+ "version": "0.3.294",
4253
4253
  "license": "UNLICENSED"
4254
4254
  },
4255
4255
  "packages/crouter-sdk": {
4256
4256
  "name": "@north-light/crouter-sdk",
4257
- "version": "0.3.292",
4257
+ "version": "0.3.294",
4258
4258
  "license": "UNLICENSED",
4259
4259
  "dependencies": {
4260
4260
  "@north-light/crouter-api": "*"