@gregiteen/ssss-cli 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-10-29
11
+ ### Added
12
+ - `FileIdempotencyStore` now expires entries. It accepts `retentionMs` (default
13
+ 24 hours, `0` disables) and `pruneEvery` (default every 500 puts), and exposes
14
+ `prune()`, which removes entries older than the window and returns the count.
15
+ Retention runs opportunistically inside `put` and never rejects a committed
16
+ operation. Previously entries were written and never removed, so a directory
17
+ only grew: one frequently rewritten document produced 14,438 records from a
18
+ single path, and a live vault reached 283k files / 1.2 GB, which slowed every
19
+ traversal of it. A retry arrives within minutes, so a bounded window loses
20
+ nothing that mattered. Lock and temp files are never treated as entries.
21
+
22
+ ## [0.11.2] - 2026-10-19
23
+ ### Added
24
+ - `parseFrontmatter(fm, { strict: true })` throws a `SyntaxError` on lines that
25
+ are not `key: value`, orphaned indentation, and malformed quoted or inline
26
+ values, so hosts such as Total Recall can report unreadable files. The default
27
+ stays lenient. Strict mode accepts all 30,518 files in 57 Total Recall vaults.
28
+
10
29
  ## [0.11.1] - 2026-10-19
11
30
  ### Fixed
12
31
  - The frontmatter parser (`@gregiteen/ssss-cli/frontmatter`) now reads the YAML
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.1
1
+ 0.12.0
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.11.1"
109
+ "exporter": "@gregiteen/ssss-cli@0.12.0"
110
110
  }
111
111
  },
112
112
  "files": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -89,6 +89,16 @@ check('single-line files with literal \\n are repaired on read', () => {
89
89
  assert.equal(parseDocument('---\nk: a\\nb\n---\n').data.k, 'a\\nb');
90
90
  });
91
91
 
92
+ check('strict mode throws on malformed YAML that lenient mode skips', () => {
93
+ const bad = ': : :\nnot: [valid\n';
94
+ assert.doesNotThrow(() => parseFrontmatter(bad));
95
+ assert.throws(() => parseFrontmatter(bad, { strict: true }), SyntaxError);
96
+ assert.throws(() => parseFrontmatter('k: "open\nj: 1\n', { strict: true }), SyntaxError);
97
+ assert.throws(() => parseFrontmatter('a: 1\n b: 2\n', { strict: true }), SyntaxError);
98
+ assert.deepEqual(parseFrontmatter('a: [1, 2]\nb: {c: d}\n', { strict: true }), { a: [1, 2], b: { c: 'd' } });
99
+ assert.doesNotThrow(() => parseFrontmatter(bad)); // strict does not leak into later calls
100
+ });
101
+
92
102
  check('serialize → parse round-trips tricky values', () => {
93
103
  const data = {
94
104
  'allowed-tools': ['Read', 'Bash'],
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
2
3
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -123,6 +124,32 @@ async function checkFileAdapterRaces(root, principal, check) {
123
124
  check('filesystem idempotency store accepts one of several processes putting the same key',
124
125
  winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
125
126
 
127
+ // Retention: an entry exists to reject a retry, which arrives within minutes,
128
+ // so entries expire rather than living forever. Without this a single
129
+ // frequently rewritten document produced 14,438 records and a live vault
130
+ // reached 283k files / 1.2 GB, slowing every traversal of the vault.
131
+ const retentionRoot = path.join(root, 'idempotency-retention');
132
+ const retention = new FileIdempotencyStore(retentionRoot, { retentionMs: 60_000, pruneEvery: 1 });
133
+ await retention.put('acme', 'fresh', { request_hash: 'sha256:fresh' });
134
+ const entryName = (workspaceId, idempotencyKey) =>
135
+ `${crypto.createHash('sha256').update(JSON.stringify([workspaceId, idempotencyKey])).digest('hex')}.json`;
136
+ const staleFile = path.join(retentionRoot, entryName('acme', 'stale'));
137
+ fs.writeFileSync(staleFile, '{"request_hash":"sha256:stale"}\n', { mode: 0o600 });
138
+ const aged = new Date(Date.now() - 10 * 60 * 1000);
139
+ fs.utimesSync(staleFile, aged, aged);
140
+ const removed = retention.prune();
141
+ check('filesystem idempotency store expires entries past the retention window',
142
+ removed === 1 && !fs.existsSync(staleFile), `removed ${removed}`);
143
+ check('filesystem idempotency store keeps entries inside the retention window',
144
+ (await retention.get('acme', 'fresh'))?.request_hash === 'sha256:fresh');
145
+ check('filesystem idempotency store skips retention when the window is disabled',
146
+ new FileIdempotencyStore(retentionRoot, { retentionMs: 0 }).prune() === 0);
147
+ const lockName = path.join(retentionRoot, `${entryName('acme', 'locked')}.lock`);
148
+ fs.writeFileSync(lockName, '');
149
+ const lockSurvived = retention.prune() === 0 && fs.existsSync(lockName);
150
+ fs.rmSync(lockName, { force: true });
151
+ check('filesystem idempotency store never treats a lock file as an entry', lockSurvived);
152
+
126
153
  const kernelRoot = path.join(root, 'kernel');
127
154
  const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
128
155
  const stores = () => ({
@@ -346,6 +346,35 @@ async function runBundleConformance() {
346
346
  fs.rmSync(atomicVault, { recursive: true, force: true });
347
347
  }
348
348
 
349
+ const referenceVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-refs-'));
350
+ const missingVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-missing-'));
351
+ try {
352
+ const engine = createEngine({ registryDir: REGISTRY_DIR });
353
+ const envelope = (id, target, fields) => ({
354
+ type: 'operation', workspace_id: 'ws-refs', idempotency_key: id,
355
+ path: target, actor: { role: 'system' },
356
+ content: ['---', 'title: Reference test', 'description: Complete planned references.',
357
+ 'timestamp: 2026-10-28T00:00:00Z', ...fields, '---', '', 'Exact payload.', ''].join('\n'),
358
+ });
359
+ const asset = envelope('ref-asset', 'assets/source.md', [
360
+ 'type: asset', 'name: source', 'mime_type: text/plain', 'encoding: utf-8',
361
+ 'resource_ref: resources/store.md',
362
+ ]);
363
+ const resource = envelope('ref-resource', 'resources/store.md', [
364
+ 'type: resource', 'name: store', 'kind: storage', 'status: bound',
365
+ ]);
366
+ const imported = await importBundle([asset, resource], referenceVault, engine);
367
+ check('fresh import resolves a forward reference from the complete plan',
368
+ imported.ok && imported.committed === 2,
369
+ JSON.stringify(imported.results.map(item => item.validation?.errors || [])));
370
+ const missing = await importBundle([asset], missingVault, engine);
371
+ check('missing planned reference rejects import without partial writes',
372
+ !missing.ok && missing.committed === 0 && !fs.existsSync(path.join(missingVault, 'assets/source.md')));
373
+ } finally {
374
+ fs.rmSync(referenceVault, { recursive: true, force: true });
375
+ fs.rmSync(missingVault, { recursive: true, force: true });
376
+ }
377
+
349
378
  let pass = 0;
350
379
  for (const c of checks) {
351
380
  if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
@@ -42,6 +42,11 @@ When an SSSS engine processes a write, it receives an **Envelope**:
42
42
  ### 4. Bundles (.ucw)
43
43
  An SSSS vault can be packaged into a `.ucw` (Universal Containerized Workspace) bundle. A bundle is a portable snapshot containing a manifest, deterministic file paths, and content hashes for cryptographic integrity.
44
44
 
45
+ Bundle import validates the complete plan before committing. Hosts pass planned
46
+ document lookup through the kernel context's `resolveReference` option so valid
47
+ forward references resolve in an empty destination. Missing referenced documents
48
+ must reject the whole import; never weaken constraints to bypass ordering.
49
+
45
50
  ## Validation
46
51
 
47
52
  If you are writing tests or schemas in a host repository, ensure that:
package/src/bundle.mjs CHANGED
@@ -508,7 +508,7 @@ export async function importBundle(plan, vaultRoot, engine) {
508
508
  });
509
509
  }
510
510
  const operationOptions = {
511
- referenceResolver: (referencePath) => plannedDocuments.get(referencePath),
511
+ resolveReference: (referencePath) => plannedDocuments.get(referencePath),
512
512
  };
513
513
  // Two-phase import: validate the complete plan with in-plan references first.
514
514
  // A single invalid document prevents every commit, eliminating partial bundle
@@ -170,16 +170,24 @@ function parseFlow(src) {
170
170
  return value;
171
171
  }
172
172
 
173
+ // Set by parseFrontmatter(fm, { strict: true }): malformed input throws instead of
174
+ // being skipped or kept as raw text.
175
+ let strictMode = false;
176
+
173
177
  function coerce(raw) {
174
178
  const v = raw.trim();
175
179
  if (v === '') return '';
176
180
  if (v[0] === '"' || v[0] === "'") {
177
181
  const end = findClosingQuote(v);
178
182
  if (end > 0 && /^\s*(#.*)?$/.test(v.slice(end + 1))) return unquote(v.slice(0, end + 1));
183
+ if (strictMode) throw new SyntaxError(`Malformed quoted value: ${v.slice(0, 60)}`);
179
184
  return v;
180
185
  }
181
186
  if (v[0] === '[' || v[0] === '{') {
182
- try { return parseFlow(v); } catch { return v; }
187
+ try { return parseFlow(v); } catch (err) {
188
+ if (strictMode) throw new SyntaxError(`Malformed inline value: ${v.slice(0, 60)}`);
189
+ return v;
190
+ }
183
191
  }
184
192
  return coercePlain(stripComment(v));
185
193
  }
@@ -214,6 +222,7 @@ function readScalar(lines, i, keyIndent, rest) {
214
222
  if (!line.trim()) { blanks++; j++; continue; }
215
223
  if (countIndent(line) <= keyIndent) break;
216
224
  if (!inline && line.trim().startsWith('#')) break;
225
+ if (strictMode && !inline && splitKeyValue(line.trim())) throw new SyntaxError(`Unexpected key in a wrapped value: ${line.trim().slice(0, 60)}`);
217
226
  const piece = inline ? line.trim() : stripComment(line.trim());
218
227
  text += blanks ? '\n'.repeat(blanks) + piece : ` ${piece}`;
219
228
  blanks = 0;
@@ -278,9 +287,17 @@ function parseMap(lines, start, indent) {
278
287
  if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
279
288
  const currentIndent = countIndent(line);
280
289
  if (currentIndent < indent) break;
281
- if (currentIndent > indent) { i++; continue; }
290
+ if (currentIndent > indent) {
291
+ if (strictMode) throw new SyntaxError(`Unexpected indentation: ${line.trim().slice(0, 60)}`);
292
+ i++;
293
+ continue;
294
+ }
282
295
  const kv = splitKeyValue(line.slice(indent));
283
- if (!kv) { i++; continue; }
296
+ if (!kv) {
297
+ if (strictMode) throw new SyntaxError(`Not a "key: value" line: ${line.trim().slice(0, 60)}`);
298
+ i++;
299
+ continue;
300
+ }
284
301
  const block = kv.rest.match(BLOCK_SCALAR_RE);
285
302
  if (block) {
286
303
  const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
@@ -369,12 +386,21 @@ function parseList(lines, start, indent) {
369
386
  }
370
387
 
371
388
  /**
372
- * Parse a frontmatter block (the inner YAML, no fences) into an object.
389
+ * Parse a frontmatter block (the inner YAML, no fences) into an object. The
390
+ * default is lenient: unreadable lines are skipped. `{ strict: true }` throws a
391
+ * SyntaxError on lines that are not `key: value`, orphaned indentation, and
392
+ * malformed quoted or inline values, so callers can report unreadable files.
373
393
  */
374
- export function parseFrontmatter(fm) {
394
+ export function parseFrontmatter(fm, { strict = false } = {}) {
375
395
  if (!fm) return {};
376
- // Drop the newline before the closing fence so it is not read as a blank line.
377
- return parseMap(fm.replace(/\n$/, '').split('\n'), 0, 0).value;
396
+ const previous = strictMode;
397
+ strictMode = strict;
398
+ try {
399
+ // Drop the newline before the closing fence so it is not read as a blank line.
400
+ return parseMap(fm.replace(/\n$/, '').split('\n'), 0, 0).value;
401
+ } finally {
402
+ strictMode = previous;
403
+ }
378
404
  }
379
405
 
380
406
  /** Parse a full document (with fences) into { data, body }. */
@@ -6,6 +6,17 @@ import { withFileLock } from './file-lock.mjs';
6
6
 
7
7
  function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
8
8
 
9
+ /**
10
+ * How long a completed operation stays replayable. A retry of the same request
11
+ * arrives within minutes (a network timeout, an agent restart); nothing needs a
12
+ * day-old result, and retaining them forever is what filled a live vault with
13
+ * 283k files. Override per store with `new FileIdempotencyStore(root, { retentionMs })`.
14
+ */
15
+ export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
16
+
17
+ /** Prune once every N puts, so retention costs one directory scan per N writes. */
18
+ export const DEFAULT_PRUNE_EVERY = 500;
19
+
9
20
  export class MemoryIdempotencyStore {
10
21
  #entries = new Map();
11
22
  async get(workspaceId, idempotencyKey) { return structuredClone(this.#entries.get(key(workspaceId, idempotencyKey)) || null); }
@@ -21,6 +32,18 @@ export class FileIdempotencyStore {
21
32
  fs.mkdirSync(root, { recursive: true });
22
33
  this.root = fs.realpathSync(root);
23
34
  this.lockOptions = options.lock || {};
35
+ // An idempotency entry exists to reject a retry of the same request. That
36
+ // window is minutes, not forever, so entries are retained for a bounded age
37
+ // and then removed. Without this the directory only grows: one frequently
38
+ // rewritten document created 14,438 records from a single path, and a live
39
+ // vault reached 283k files / 1.2 GB, which slowed every traversal of it.
40
+ this.retentionMs = Number.isFinite(options.retentionMs) && options.retentionMs >= 0
41
+ ? options.retentionMs
42
+ : DEFAULT_RETENTION_MS;
43
+ this.pruneEvery = Number.isInteger(options.pruneEvery) && options.pruneEvery > 0
44
+ ? options.pruneEvery
45
+ : DEFAULT_PRUNE_EVERY;
46
+ this.puts = 0;
24
47
  }
25
48
  #file(workspaceId, idempotencyKey) {
26
49
  const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
@@ -44,8 +67,37 @@ export class FileIdempotencyStore {
44
67
  fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
45
68
  fs.renameSync(temp, file);
46
69
  } finally { fs.rmSync(temp, { force: true }); }
70
+ // Opportunistic retention: no scheduler, and the cost is amortised over
71
+ // putPushes. Never throws — a prune failure must not reject a committed
72
+ // operation, whose result the caller is about to rely on.
73
+ this.puts += 1;
74
+ if (this.retentionMs > 0 && this.puts % this.pruneEvery === 0) {
75
+ try { this.prune(); } catch { /* retention is best-effort */ }
76
+ }
47
77
  }, this.lockOptions);
48
78
  }
79
+ /**
80
+ * Remove entries older than the retention window. Returns the count removed.
81
+ * Only regular `*.json` files are considered; lock files and temp files are
82
+ * left alone so a concurrent writer is never disturbed.
83
+ */
84
+ prune(now = Date.now()) {
85
+ if (!(this.retentionMs > 0)) return 0;
86
+ const cutoff = now - this.retentionMs;
87
+ let removed = 0;
88
+ let entries;
89
+ try { entries = fs.readdirSync(this.root); } catch { return 0; }
90
+ for (const name of entries) {
91
+ if (!name.endsWith('.json')) continue;
92
+ const file = path.join(this.root, name);
93
+ try {
94
+ const stat = fs.lstatSync(file);
95
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
96
+ if (stat.mtimeMs < cutoff) { fs.rmSync(file, { force: true }); removed += 1; }
97
+ } catch { /* a vanished or unreadable entry is not a prune failure */ }
98
+ }
99
+ return removed;
100
+ }
49
101
  }
50
102
 
51
103
  export async function runIdempotencyContract(store) {