@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 +19 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/package.json +1 -1
- package/scripts/check-frontmatter.mjs +10 -0
- package/scripts/conformance-09.mjs +27 -0
- package/scripts/conformance.mjs +29 -0
- package/skills/ssss/SKILL.md +5 -0
- package/src/bundle.mjs +1 -1
- package/src/frontmatter.mjs +33 -7
- package/src/idempotency.mjs +52 -0
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.
|
|
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.
|
|
109
|
+
"exporter": "@gregiteen/ssss-cli@0.12.0"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
package/package.json
CHANGED
|
@@ -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 = () => ({
|
package/scripts/conformance.mjs
CHANGED
|
@@ -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}`); }
|
package/skills/ssss/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/frontmatter.mjs
CHANGED
|
@@ -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 {
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
377
|
-
|
|
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 }. */
|
package/src/idempotency.mjs
CHANGED
|
@@ -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) {
|