atris 3.42.0 → 3.43.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/bin/atris.js CHANGED
@@ -538,7 +538,7 @@ function showHelp() {
538
538
  console.log(' meet - onboard a stranger in one sitting and print their /book link');
539
539
  console.log(' avail - Booking availability (/book/{username} weekly windows)');
540
540
  console.log(' brain - Compile MAP/TODO/wiki/state into a loadable agent brain');
541
- console.log(' lesson - Append a one-line lesson to atris/lessons.md (mine: distill receipts/episodes/scorecards into policy lessons)');
541
+ console.log(' lesson - Append a one-line lesson to atris/lessons.md (add --detector validates the falsifier at write time; ledger/revert audit every mutation; mine: distill receipts/episodes/scorecards into policy lessons)');
542
542
  console.log(' taste - Record the operator\'s keep, kill, and more creative verdicts');
543
543
  console.log(' teach - Turn a bad turn into a failing benchmark, then a permanent guard (red gate: no promotion without a proven failure)');
544
544
  console.log(' ingest - Local-first wiki ingest into atris/wiki/');
@@ -8,6 +8,7 @@ const {
8
8
  } = require('./autopilot');
9
9
  const { detectLessonContradictions } = require('../lib/lesson-contradiction');
10
10
  const taskDb = require('../lib/task-db');
11
+ const { validateDetector, appendLedgerEntry, readLedger } = require('../lib/lesson-ledger');
11
12
 
12
13
  /**
13
14
  * Tag a lesson's line in atris/lessons.md with `[resolved]` (idempotent).
@@ -78,6 +79,14 @@ function autoResolveLessons(cwd, options = {}) {
78
79
  if (!dryRun && resolved.length) {
79
80
  const metaPath = path.join(cwd, 'atris', 'lessons.json');
80
81
  fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');
82
+ for (const slug of resolved) {
83
+ appendLedgerEntry(cwd, {
84
+ action: 'resolve',
85
+ slug,
86
+ evidence: `detector passed: ${metadata[slug].detector}`,
87
+ outcome: `status resolved, resolved_at ${today}`,
88
+ });
89
+ }
81
90
  }
82
91
 
83
92
  return { checked, resolved, dryRun };
@@ -213,12 +222,138 @@ function mineLessons(args) {
213
222
  }
214
223
  }
215
224
 
225
+ /**
226
+ * Strip the `[resolved]` tag from a lesson's line in atris/lessons.md.
227
+ * Inverse of tagLessonResolvedInMd; used by revert.
228
+ * @returns {boolean} true if the file was changed.
229
+ */
230
+ function untagLessonResolvedInMd(cwd, slug) {
231
+ const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
232
+ if (!fs.existsSync(lessonsPath)) return false;
233
+ const lines = fs.readFileSync(lessonsPath, 'utf8').split('\n');
234
+ let changed = false;
235
+ for (let i = 0; i < lines.length; i++) {
236
+ const m = lines[i].match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
237
+ if (!m || m[1] !== slug) continue;
238
+ if (!/\[resolved\]/i.test(lines[i])) continue;
239
+ lines[i] = lines[i].replace(/\[resolved\]\s*/i, '');
240
+ changed = true;
241
+ }
242
+ if (changed) fs.writeFileSync(lessonsPath, lines.join('\n'));
243
+ return changed;
244
+ }
245
+
246
+ /**
247
+ * Add a lesson as a validated contract in one step: prose line in lessons.md,
248
+ * typed entry in the lessons.json sidecar, and a ledger record with evidence.
249
+ *
250
+ * When a detector is given it must actually run (see validateDetector):
251
+ * a lesson whose falsifier is a typo would sit unresolvable forever and rot
252
+ * trust in the whole file. Detector-less adds are still allowed (process
253
+ * notes), they just never self-retire.
254
+ *
255
+ * @param {string} cwd
256
+ * @param {string} slug kebab-case
257
+ * @param {'pass'|'fail'} status
258
+ * @param {string} explanation
259
+ * @param {{ detector?: string, scope?: string }} [opts]
260
+ * @returns {{ ok: boolean, error?: string, ledger?: object }}
261
+ */
262
+ function addLesson(cwd, slug, status, explanation, opts = {}) {
263
+ let evidence = 'prose-only (no detector)';
264
+ if (opts.detector !== undefined) {
265
+ const check = validateDetector(opts.detector, cwd);
266
+ if (!check.ok) {
267
+ return { ok: false, error: `detector rejected: ${check.reason}` };
268
+ }
269
+ evidence = `detector validated (exit ${check.exitCode}): ${opts.detector}`;
270
+ }
271
+
272
+ writeLesson(cwd, slug, status, explanation);
273
+
274
+ if (opts.detector !== undefined || opts.scope !== undefined) {
275
+ const metadata = loadLessonMetadata(cwd);
276
+ metadata[slug] = {
277
+ ...(metadata[slug] || {}),
278
+ ...(opts.detector !== undefined ? { detector: opts.detector } : {}),
279
+ ...(opts.scope !== undefined ? { scope: opts.scope } : {}),
280
+ status: (metadata[slug] && metadata[slug].status) || 'open',
281
+ };
282
+ fs.writeFileSync(
283
+ path.join(cwd, 'atris', 'lessons.json'),
284
+ JSON.stringify(metadata, null, 2) + '\n'
285
+ );
286
+ }
287
+
288
+ const ledger = appendLedgerEntry(cwd, {
289
+ action: 'add',
290
+ slug,
291
+ evidence,
292
+ outcome: `${status} lesson written`,
293
+ });
294
+ return { ok: true, ledger };
295
+ }
296
+
297
+ /**
298
+ * Reopen a resolved lesson: sidecar back to open, [resolved] tag stripped,
299
+ * revert recorded in the ledger. The rollback half of auto-resolve: a
300
+ * detector that passed for the wrong reason (deleted call site, gamed check)
301
+ * must be reversible without hand-editing two files.
302
+ * @returns {{ ok: boolean, error?: string, ledger?: object }}
303
+ */
304
+ function revertLessonResolution(cwd, slug, reason) {
305
+ const metadata = loadLessonMetadata(cwd);
306
+ const meta = metadata[slug];
307
+ if (!meta) return { ok: false, error: `no sidecar entry for "${slug}"` };
308
+ if (meta.status !== 'resolved') {
309
+ return { ok: false, error: `"${slug}" is not resolved (status: ${meta.status || 'open'})` };
310
+ }
311
+ const prevResolvedAt = meta.resolved_at;
312
+ delete meta.resolved_at;
313
+ meta.status = 'open';
314
+ metadata[slug] = meta;
315
+ fs.writeFileSync(
316
+ path.join(cwd, 'atris', 'lessons.json'),
317
+ JSON.stringify(metadata, null, 2) + '\n'
318
+ );
319
+ untagLessonResolvedInMd(cwd, slug);
320
+ const ledger = appendLedgerEntry(cwd, {
321
+ action: 'revert',
322
+ slug,
323
+ evidence: reason || 'manual revert',
324
+ outcome: `reopened (was resolved ${prevResolvedAt || 'unknown'})`,
325
+ });
326
+ return { ok: true, ledger };
327
+ }
328
+
329
+ function showLedger(args) {
330
+ const cwd = process.cwd();
331
+ const json = args.includes('--json');
332
+ const limitIdx = args.indexOf('--limit');
333
+ const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) || 20 : 20;
334
+ const records = readLedger(cwd, { limit });
335
+
336
+ if (json) {
337
+ console.log(JSON.stringify({ ok: true, action: 'lesson_ledger', count: records.length, records }, null, 2));
338
+ return;
339
+ }
340
+ if (!records.length) {
341
+ console.log('ledger is empty (no lesson mutations recorded yet)');
342
+ return;
343
+ }
344
+ for (const r of records) {
345
+ console.log(`${(r.ts || '').slice(0, 16)} ${r.action.padEnd(7)} ${r.slug}: ${r.evidence || ''}`);
346
+ }
347
+ }
348
+
216
349
  function printLessonUsage() {
217
350
  console.log('');
218
- console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
351
+ console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>" [--detector "<cmd>"] [--scope <scope>]');
219
352
  console.log(' atris lesson mine [--json] [--dry-run]');
220
353
  console.log(' atris lesson sweep [--json] [--dry-run]');
221
354
  console.log(' atris lesson resolve [--json] [--dry-run]');
355
+ console.log(' atris lesson revert <slug> ["<reason>"]');
356
+ console.log(' atris lesson ledger [--json] [--limit N]');
222
357
  console.log('');
223
358
  }
224
359
 
@@ -251,12 +386,44 @@ function lessonAtris(subcommand, ...args) {
251
386
  return;
252
387
  }
253
388
 
389
+ if (subcommand === 'ledger') {
390
+ showLedger(args);
391
+ return;
392
+ }
393
+
394
+ if (subcommand === 'revert') {
395
+ const [slug, ...reasonParts] = args;
396
+ if (!slug) {
397
+ console.error(' ✗ usage: atris lesson revert <slug> ["<reason>"]');
398
+ process.exit(1);
399
+ }
400
+ const res = revertLessonResolution(process.cwd(), slug, reasonParts.join(' ').trim() || undefined);
401
+ if (!res.ok) {
402
+ console.error(` ✗ ${res.error}`);
403
+ process.exit(1);
404
+ }
405
+ console.log(`✓ lesson reopened: ${slug} (ledger ${res.ledger.id})`);
406
+ return;
407
+ }
408
+
254
409
  if (subcommand !== 'add') {
255
410
  printLessonUsage();
256
411
  process.exit(subcommand ? 1 : 0);
257
412
  }
258
413
 
259
- const [slug, status, ...messageParts] = args;
414
+ // Pull --detector/--scope flag pairs out before positional parsing.
415
+ const opts = {};
416
+ const positional = [];
417
+ for (let i = 0; i < args.length; i++) {
418
+ if (args[i] === '--detector') {
419
+ opts.detector = args[++i];
420
+ } else if (args[i] === '--scope') {
421
+ opts.scope = args[++i];
422
+ } else {
423
+ positional.push(args[i]);
424
+ }
425
+ }
426
+ const [slug, status, ...messageParts] = positional;
260
427
  const explanation = messageParts.join(' ').trim();
261
428
 
262
429
  if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
@@ -274,9 +441,16 @@ function lessonAtris(subcommand, ...args) {
274
441
  process.exit(1);
275
442
  }
276
443
 
277
- writeLesson(process.cwd(), slug, status, explanation);
278
- console.log(`✓ lesson added: ${slug} (${status})`);
444
+ const res = addLesson(process.cwd(), slug, status, explanation, opts);
445
+ if (!res.ok) {
446
+ console.error(` ✗ ${res.error}`);
447
+ process.exit(1);
448
+ }
449
+ const typed = opts.detector ? ' [detector validated]' : '';
450
+ console.log(`✓ lesson added: ${slug} (${status})${typed}`);
279
451
  }
280
452
 
281
453
  module.exports = lessonAtris;
282
454
  module.exports.autoResolveLessons = autoResolveLessons;
455
+ module.exports.addLesson = addLesson;
456
+ module.exports.revertLessonResolution = revertLessonResolution;
@@ -0,0 +1,84 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { spawnSync } = require('child_process');
5
+
6
+ /**
7
+ * Lesson contract validation + append-only mutation ledger.
8
+ *
9
+ * Every lesson mutation (add / resolve / revert) lands one JSONL record in
10
+ * .atris/state/lesson_ledger.jsonl with evidence and an id, so self-improvement
11
+ * edits are auditable and reversible instead of silent sidecar rewrites. The
12
+ * ledger is append-only: revert writes a new record, it never deletes history.
13
+ */
14
+
15
+ const LEDGER_REL = path.join('.atris', 'state', 'lesson_ledger.jsonl');
16
+
17
+ function ledgerPath(root) {
18
+ return path.join(root, LEDGER_REL);
19
+ }
20
+
21
+ /**
22
+ * Validate a detector command at write time. A detector is a shell command
23
+ * whose exit code is the lesson's falsifiable state (0 = bug gone). At add
24
+ * time either exit code is fine (the bug usually still exists) but the
25
+ * command itself must run: empty strings, missing binaries (127), and
26
+ * non-executable targets (126) are rejected so a typo can't masquerade as a
27
+ * forever-failing detector.
28
+ * @returns {{ ok: boolean, exitCode?: number, reason?: string }}
29
+ */
30
+ function validateDetector(cmd, cwd, timeoutMs = 10000) {
31
+ if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
32
+ return { ok: false, reason: 'detector is empty' };
33
+ }
34
+ const res = spawnSync(cmd, { shell: true, cwd, timeout: timeoutMs, stdio: 'pipe' });
35
+ if (res.error) return { ok: false, reason: res.error.message };
36
+ if (res.signal) return { ok: false, reason: `detector killed by ${res.signal} (timeout?)` };
37
+ if (res.status === 127) return { ok: false, exitCode: 127, reason: 'command not found (exit 127)' };
38
+ if (res.status === 126) return { ok: false, exitCode: 126, reason: 'command not executable (exit 126)' };
39
+ return { ok: true, exitCode: res.status };
40
+ }
41
+
42
+ /**
43
+ * Append one mutation record. Adds id + ts; returns the full record.
44
+ * @param {string} root
45
+ * @param {{ action: 'add'|'resolve'|'revert', slug: string, evidence?: string, outcome?: string }} entry
46
+ */
47
+ function appendLedgerEntry(root, entry) {
48
+ const file = ledgerPath(root);
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const record = {
51
+ id: `ll-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
52
+ ts: new Date().toISOString(),
53
+ ...entry,
54
+ };
55
+ fs.appendFileSync(file, JSON.stringify(record) + '\n');
56
+ return record;
57
+ }
58
+
59
+ /**
60
+ * Read ledger records, oldest first. Malformed lines are skipped, not fatal:
61
+ * a half-written line from a crashed process must not brick the view.
62
+ * @param {string} root
63
+ * @param {{ limit?: number, slug?: string }} [options]
64
+ */
65
+ function readLedger(root, options = {}) {
66
+ const file = ledgerPath(root);
67
+ if (!fs.existsSync(file)) return [];
68
+ const records = [];
69
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
70
+ if (!line.trim()) continue;
71
+ try {
72
+ records.push(JSON.parse(line));
73
+ } catch {
74
+ // skip torn line
75
+ }
76
+ }
77
+ const filtered = options.slug ? records.filter((r) => r.slug === options.slug) : records;
78
+ if (options.limit && filtered.length > options.limit) {
79
+ return filtered.slice(filtered.length - options.limit);
80
+ }
81
+ return filtered;
82
+ }
83
+
84
+ module.exports = { ledgerPath, validateDetector, appendLedgerEntry, readLedger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.42.0",
3
+ "version": "3.43.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {