@jaimevalasek/aioson 1.21.0 → 1.21.3

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
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.21.3] - 2026-05-28
8
+
9
+ ### Security
10
+ - **`memory:trim --archive=<path>` is now contained under the project root (TS-LC-01).** It was resolved relative to `cwd` with no containment, so a crafted/typo'd path could write or overwrite a file outside the project. Now resolved under the project root and rejected with `archive_path_escape` on absolute or `..`-traversal escape — mirroring the containment wall in `memory-reflect-commit`. Localized message added in all 4 locales.
11
+ - **`feature:close` auto-trim hook now honors `AIOSON_RUNTIME_HOOK` (TS-LC-02).** The hook called the trim engine directly, bypassing the hook-context guard that `memory:trim` enforces. It now skips when running in a hook/automation context, so a tier-2 memory mutation never fires outside explicit human action.
12
+
13
+ ### Tests
14
+ - Coverage pass over the v1.21.2 agent-loading-contract code (`node --test --experimental-test-coverage`): `current-state-trim.js` 98.8%→100% line, `memory-trim.js` 73.9%→88.6% line / 64.4%→76.7% branch. Adds error/edge-path tests (`no_current_state`, `section_not_found`, custom/escaping `--archive`, headerless archive, hook skip paths) and verifies all `cli.memory_{archive,restore,search}` keys resolve.
15
+
16
+ ## [1.21.2] - 2026-05-28
17
+
18
+ ### Added
19
+ - **Agent loading contract + `memory:trim`.** `bootstrap/current-state.md` is an append-only log that every implementation/review agent read in full at activation (~81KB / ~33k tokens, 84% of the bootstrap). New `aioson memory:trim [--keep=<N>] [--archive=<path>] [--dry-run] [--json]` splits its "## What the system already has" section into a HOT log + a cold `current-state-archive.md` (entries MOVED verbatim, never deleted; active-feature entries exempt). `feature:close` (PASS) auto-rolls aged entries (`--no-trim` to opt out). New governance doc `.aioson/design-docs/agent-loading-contract.md` defines the three loading tiers + retention policy. The repo's own current-state was trimmed 81KB → 21KB.
20
+ - **`context:health` now measures `bootstrap/*.md`** — the per-activation layer it previously ignored — and excludes the cold `*-archive.md`; a heavy `current-state.md` now points to `memory:trim`.
21
+ - **Shared code-health analysis lens** `.aioson/docs/quality/code-health-analysis.md` (plan → investigate → refine → operate → test → adjust over coverage, test sufficiency, regression need, execution-chain, performance, componentization), wired on-demand into `@tester`/`@qa`/`@pentester`/`@architect`/`@sheldon`/`@deyvin`.
22
+ - **Current-state entry tagging** — the reflect engine, `@dev`, and `@committer` now prefix new entries with `[{slug} · {YYYY-MM-DD}]` for precise rollup; `@qa`/`@architect`/`@dev`/`@deyvin` bootstrap sections gained archive-awareness (grep the archive before flagging a capability as missing).
23
+
24
+ ### Fixed
25
+ - **`memory:archive` / `memory:restore` / `memory:search` logged raw i18n keys** in every locale — they called message keys without the required `cli.` namespace prefix, so `t()` missed and echoed the key (e.g. `memory_archive.id_required`). All 25 calls now use the `cli.` prefix and localize correctly.
26
+
27
+ ## [1.21.1] - 2026-06-XX
28
+
29
+ ### Fixed
30
+ - **`memory:reflect-commit --dry-run` is now non-destructive.** The command never read the `--dry-run` flag, so a "dry run" silently performed the full destructive commit — it wrote the bootstrap files **and** unlinked the single-use manifest, leaving the flow unrecoverable (`missing_manifest`) on the next call. `--dry-run` now runs validation + path containment exactly like a real commit, then returns `{ ok: true, dryRun: true, would_write: [...] }` without writing any file or consuming the manifest, so a real commit can still follow. Regression coverage in `tests/memory-reflect-commit-dry-run.test.js`. Note: the reflect manifest remains single-use — a successful real commit consumes it (re-run by re-running `memory:reflect-prepare`).
31
+
7
32
  ## [1.21.0] - 2026-06-XX
8
33
 
9
34
  ### Added
@@ -121,6 +121,8 @@ O agente é livre para usar o LLM como achar melhor para fazer a reescrita. O CL
121
121
 
122
122
  Se tudo passa: escreve, apaga o manifest, emite `memory_reflect_committed` em runtime. Se falha: emite `memory_reflect_failed` com a lista de erros e o agente tem 1 retry.
123
123
 
124
+ > **`--dry-run` (pré-visualização não-destrutiva):** `aioson memory:reflect-commit . --agent=dev --output=<json> --dry-run` roda a validação + containment de path exatamente como o commit real, mas **não escreve nada e não consome o manifest** — retorna `{ ok: true, dryRun: true, would_write: [...] }`. Use pra conferir o output antes de aplicar. Atenção: o manifest é **single-use** — um commit real (sem `--dry-run`) o apaga; pra recomeçar, rode `memory:reflect-prepare` de novo.
125
+
124
126
  ## Exemplo end-to-end
125
127
 
126
128
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaimevalasek/aioson",
3
- "version": "1.21.0",
3
+ "version": "1.21.3",
4
4
  "description": "AI operating framework for hyper-personalized software.",
5
5
  "keywords": [
6
6
  "ai",
package/src/cli.js CHANGED
@@ -18,6 +18,7 @@ const { runChainAudit } = require('./commands/chain-audit');
18
18
  const { runMemorySearch } = require('./commands/memory-search');
19
19
  const { runMemoryArchive } = require('./commands/memory-archive');
20
20
  const { runMemoryRestore } = require('./commands/memory-restore');
21
+ const { runMemoryTrim } = require('./commands/memory-trim');
21
22
  const { runSetupContext } = require('./commands/setup-context');
22
23
  const { runLocaleApply } = require('./commands/locale-apply');
23
24
  const { runSmokeTest } = require('./commands/smoke');
@@ -525,6 +526,8 @@ const JSON_SUPPORTED_COMMANDS = new Set([
525
526
  'memory-archive',
526
527
  'memory:restore',
527
528
  'memory-restore',
529
+ 'memory:trim',
530
+ 'memory-trim',
528
531
  'memory:reflect-prepare',
529
532
  'memory-reflect-prepare',
530
533
  'memory:reflect-commit',
@@ -1355,6 +1358,8 @@ async function main() {
1355
1358
  result = await runMemoryArchive({ args, options, logger: commandLogger, t });
1356
1359
  } else if (command === 'memory:restore' || command === 'memory-restore') {
1357
1360
  result = await runMemoryRestore({ args, options, logger: commandLogger, t });
1361
+ } else if (command === 'memory:trim' || command === 'memory-trim') {
1362
+ result = await runMemoryTrim({ args, options, logger: commandLogger, t });
1358
1363
  } else if (command === 'memory:reflect-prepare' || command === 'memory-reflect-prepare') {
1359
1364
  result = await runMemoryReflectPrepare({ args, options, logger: commandLogger });
1360
1365
  } else if (command === 'memory:reflect-commit' || command === 'memory-reflect-commit') {
@@ -84,6 +84,34 @@ async function runContextHealth({ args, options = {}, logger }) {
84
84
  } catch { /* skip unreadable files */ }
85
85
  }
86
86
 
87
+ // bootstrap/*.md is the per-activation memory layer: dev/qa/architect/deyvin
88
+ // read it on every session start, so it dominates the real activation cost.
89
+ // It lives in a subdir, so the top-level scan above missed it entirely —
90
+ // include it here so the heaviest layer is visible, not hidden (P0 of the
91
+ // agent-loading-contract). Backward-compatible: no bootstrap/ dir → no change.
92
+ const bootstrapDir = path.join(contextDir, 'bootstrap');
93
+ let bootstrapFiles = [];
94
+ try {
95
+ // Exclude *-archive.md: cold storage is never loaded at activation, so
96
+ // counting it would inflate the report and mislabel intended bulk as CRITICAL.
97
+ bootstrapFiles = (await fs.readdir(bootstrapDir))
98
+ .filter((f) => f.endsWith('.md') && !f.endsWith('-archive.md'));
99
+ } catch { /* no bootstrap dir — pre-Living-Memory projects */ }
100
+ for (const file of bootstrapFiles) {
101
+ try {
102
+ const content = await fs.readFile(path.join(bootstrapDir, file), 'utf8');
103
+ const tokens = estimateTokens(content);
104
+ totalTokens += tokens;
105
+ report.push({
106
+ file: `bootstrap/${file}`,
107
+ sizeBytes: content.length,
108
+ tokens,
109
+ heavy: tokens > HEAVY_TOKEN_THRESHOLD,
110
+ critical: tokens > CRITICAL_TOKEN_THRESHOLD
111
+ });
112
+ } catch { /* skip unreadable files */ }
113
+ }
114
+
87
115
  report.sort((a, b) => b.tokens - a.tokens);
88
116
 
89
117
  const doneFeatures = await loadFeatureStatuses(contextDir);
@@ -150,8 +178,13 @@ async function runContextHealth({ args, options = {}, logger }) {
150
178
  for (const r of heavyFiles) {
151
179
  const label = r.critical ? 'CRITICAL' : 'heavy';
152
180
  logger.log(`⚠ ${r.file} is ${label} (${formatBytes(r.sizeBytes)}). Consider:`);
153
- logger.log(` → Run: aioson context:pack . --scope=<feature>`);
154
- logger.log(` Creates a scoped context for a specific feature`);
181
+ if (r.file === 'bootstrap/current-state.md') {
182
+ logger.log(` Run: aioson memory:trim . --dry-run`);
183
+ logger.log(` Archives old log entries out of the hot bootstrap (every agent reads this at activation)`);
184
+ } else {
185
+ logger.log(` → Run: aioson context:pack . --scope=<feature>`);
186
+ logger.log(` Creates a scoped context for a specific feature`);
187
+ }
155
188
  }
156
189
  logger.log('');
157
190
  }
@@ -25,6 +25,12 @@ const { loadConfig } = require('../sub-task-engine');
25
25
  const { runDistillation, readFeatureClassification } = require('../learning-loop-engine');
26
26
  const { openRuntimeDb } = require('../runtime-store');
27
27
  const { runNotify } = require('./notify');
28
+ const { splitCurrentState, buildArchiveContent, parseActiveSlugs } = require('../current-state-trim');
29
+
30
+ // P0 agent-loading-contract: a feature closing is the natural cadence to roll
31
+ // aged-out current-state.md entries into the cold archive. Conservative window
32
+ // (gentle, automatic) — manual `memory:trim --keep=<N>` can trim harder.
33
+ const AUTO_CLOSE_KEEP = 25;
28
34
 
29
35
  function nowDate() {
30
36
  return new Date().toISOString().slice(0, 10);
@@ -531,6 +537,36 @@ async function runFeatureClose({ args, options = {}, logger }) {
531
537
  updates.push('distill: skipped (--no-distill flag)');
532
538
  }
533
539
 
540
+ // Auto-rollup bootstrap/current-state.md (P0 agent-loading-contract). The
541
+ // just-closed slug is already `done` in features.md, so it no longer counts as
542
+ // an active-slug exemption — its aged entries become eligible. Best-effort and
543
+ // non-blocking: a failure here must never break the closure. Opt out: --no-trim.
544
+ // SECURITY (TS-LC-02): the trim hook calls the engine directly, bypassing the
545
+ // AIOSON_RUNTIME_HOOK guard that memory:trim enforces. Honor that guard here
546
+ // too, so a tier-2 memory mutation never fires inside a hook/automation context.
547
+ const skipTrim = options['no-trim'] === true || options.trim === false
548
+ || process.env.AIOSON_RUNTIME_HOOK === '1';
549
+ if (verdict === 'PASS' && !skipTrim) {
550
+ try {
551
+ const csPath = path.join(targetDir, '.aioson/context/bootstrap/current-state.md');
552
+ const csContent = await readFileSafe(csPath);
553
+ if (csContent) {
554
+ const activeSlugs = parseActiveSlugs((await readFileSafe(path.join(targetDir, '.aioson/context/features.md'))) || '');
555
+ const split = splitCurrentState(csContent, { keep: AUTO_CLOSE_KEEP, activeSlugs });
556
+ if (split.ok && split.archivedEntries.length > 0) {
557
+ const archPath = path.join(targetDir, '.aioson/context/bootstrap/current-state-archive.md');
558
+ const eol = /\r\n/.test(csContent) ? '\r\n' : '\n';
559
+ const existingArchive = (await readFileSafe(archPath)) || '';
560
+ await fs.writeFile(archPath, buildArchiveContent(existingArchive, split.archivedEntries, nowDate(), eol), 'utf8');
561
+ await fs.writeFile(csPath, split.hotContent, 'utf8');
562
+ updates.push(`trim: archived ${split.archivedEntries.length} aged current-state entries (kept ${split.stats.kept})`);
563
+ }
564
+ }
565
+ } catch (err) {
566
+ updates.push(`trim: hook error (${(err && err.message) || err})`);
567
+ }
568
+ }
569
+
534
570
  const result = {
535
571
  ok: true,
536
572
  feature: slug,
@@ -1,193 +1,193 @@
1
- 'use strict';
2
-
3
- /**
4
- * aioson memory:archive --id=<rule|learning|brain>:<slug> --reason="<text>" [--dry-run] [--json]
5
- *
6
- * Tier-2 human-actioned command (PMD-4 / Article VII / BR-ALL-01). Moves a
7
- * curated artifact to `_archived/{YYYY-MM-DD}/` and records the transition in
8
- * `evolution_log` using the validity-window pattern (BR-ALL-02 append-only):
9
- * - supersedes the prior active entry (sets `end_at`)
10
- * - inserts a new `event_type='archived'` row
11
- * - for learnings: also flips `project_learnings.status` to 'archived'
12
- *
13
- * Refuses to run when `process.env.AIOSON_RUNTIME_HOOK === '1'` (BR-ALL-01:
14
- * hook code paths are never permitted to archive — only direct invocation by
15
- * a human).
16
- *
17
- * Emits `aioson notify --level=warn` BEFORE mutating disk or DB (BR-ALL-06).
18
- */
19
-
20
- const path = require('node:path');
21
- const { openRuntimeDb } = require('../runtime-store');
22
- const { runNotify } = require('./notify');
23
- const {
24
- parseTargetId,
25
- normalizeKind,
26
- archiveTarget,
27
- TARGET_TYPES
28
- } = require('../learning-loop-archive');
29
-
30
- function tFn(t, key, params) {
31
- if (typeof t === 'function') {
32
- try { return t(key, params || {}); } catch { /* fall through */ }
33
- }
34
- return null;
35
- }
36
-
37
- function isHookContext() {
38
- return process.env.AIOSON_RUNTIME_HOOK === '1';
39
- }
40
-
41
- async function runMemoryArchive({ args, options = {}, logger, t }) {
42
- const targetDir = path.resolve(process.cwd(), args && args[0] ? args[0] : '.');
43
- const wantJson = Boolean(options.json);
44
- const dryRun = Boolean(options['dry-run'] || options.dryRun);
45
- const log = (msg) => { if (logger && typeof logger.log === 'function') logger.log(msg); };
46
-
47
- if (isHookContext()) {
48
- const msg = tFn(t, 'memory_archive.hook_blocked')
49
- || 'memory:archive cannot be invoked from a runtime hook (BR-ALL-01: tier-2 requires human action).';
50
- if (wantJson) return { ok: false, reason: 'hook_blocked' };
51
- log(msg);
52
- return { ok: false, reason: 'hook_blocked' };
53
- }
54
-
55
- const rawId = options.id || options.target || '';
56
- const reason = options.reason ? String(options.reason).trim() : '';
57
-
58
- if (!rawId) {
59
- const msg = tFn(t, 'memory_archive.id_required')
60
- || 'memory:archive requires --id=<rule|learning|brain>:<slug>.';
61
- if (wantJson) return { ok: false, reason: 'missing_id' };
62
- log(msg);
63
- return { ok: false, reason: 'missing_id' };
64
- }
65
- if (!reason) {
66
- const msg = tFn(t, 'memory_archive.reason_required')
67
- || 'memory:archive requires --reason="<text>".';
68
- if (wantJson) return { ok: false, reason: 'missing_reason' };
69
- log(msg);
70
- return { ok: false, reason: 'missing_reason' };
71
- }
72
-
73
- const parsed = parseTargetId(rawId);
74
- const kind = normalizeKind(parsed.kind);
75
- if (!kind || !TARGET_TYPES.has(kind)) {
76
- const msg = tFn(t, 'memory_archive.invalid_id', { value: rawId })
77
- || `memory:archive invalid --id value: "${rawId}". Expected rule|learning|brain:<slug>.`;
78
- if (wantJson) return { ok: false, reason: 'invalid_id', value: rawId };
79
- log(msg);
80
- return { ok: false, reason: 'invalid_id' };
81
- }
82
- if (!parsed.slug) {
83
- const msg = tFn(t, 'memory_archive.invalid_id', { value: rawId })
84
- || `memory:archive invalid --id value: "${rawId}". Missing slug after ":".`;
85
- if (wantJson) return { ok: false, reason: 'invalid_id', value: rawId };
86
- log(msg);
87
- return { ok: false, reason: 'invalid_id' };
88
- }
89
-
90
- // BR-ALL-06: emit tier-2 notify BEFORE any mutation (defense in depth).
91
- // notify.runNotify returns { ok, exitCode } — non-zero aborts.
92
- const notifyMessage = tFn(t, 'memory_archive.notify_template', { kind, slug: parsed.slug, reason })
93
- || `archiving ${kind} "${parsed.slug}": ${reason}`;
94
- let notifyResult;
95
- try {
96
- notifyResult = await runNotify({
97
- args: [targetDir],
98
- options: {
99
- level: 'warn',
100
- topic: 'memory',
101
- message: notifyMessage,
102
- agent: 'memory-archive',
103
- json: wantJson ? true : undefined
104
- },
105
- logger: logger || { log: () => {} }
106
- });
107
- } catch (err) {
108
- if (wantJson) return { ok: false, reason: 'notify_failed', error: String(err && err.message || err) };
109
- log(`memory:archive notify failed: ${err && err.message ? err.message : err}`);
110
- return { ok: false, reason: 'notify_failed' };
111
- }
112
- if (notifyResult && notifyResult.ok === false) {
113
- if (wantJson) return { ok: false, reason: 'notify_blocked', exitCode: notifyResult.exitCode };
114
- log('memory:archive aborted: tier-2 notify returned non-zero exit code.');
115
- return { ok: false, reason: 'notify_blocked' };
116
- }
117
-
118
- let dbHandle;
119
- try {
120
- dbHandle = await openRuntimeDb(targetDir);
121
- } catch (err) {
122
- if (wantJson) return { ok: false, reason: 'runtime_db_unavailable', error: String(err && err.message || err) };
123
- log(`memory:archive runtime db unavailable: ${err && err.message ? err.message : err}`);
124
- return { ok: false, reason: 'runtime_db_unavailable' };
125
- }
126
-
127
- const { db } = dbHandle;
128
- let outcome;
129
- try {
130
- outcome = archiveTarget(db, {
131
- targetDir,
132
- kind,
133
- slug: parsed.slug,
134
- reason,
135
- actor: 'human',
136
- featureSlug: options.feature ? String(options.feature).trim() : null,
137
- dryRun
138
- });
139
- } finally {
140
- db.close();
141
- }
142
-
143
- if (!outcome.ok) {
144
- if (wantJson) return outcome;
145
- if (outcome.reason === 'already_archived') {
146
- const msg = tFn(t, 'memory_archive.already_archived', { path: outcome.archivedAt })
147
- || `memory:archive: "${parsed.slug}" already archived (${outcome.archivedAt || 'unknown path'}). No-op.`;
148
- log(msg);
149
- } else if (outcome.reason === 'target_not_found') {
150
- const msg = tFn(t, 'memory_archive.target_not_found', { kind, slug: parsed.slug })
151
- || `memory:archive: ${kind} "${parsed.slug}" not found in active state.`;
152
- log(msg);
153
- } else {
154
- log(`memory:archive failed: ${outcome.reason}${outcome.error ? ' — ' + outcome.error : ''}`);
155
- }
156
- return outcome;
157
- }
158
-
159
- if (wantJson) {
160
- return {
161
- ok: true,
162
- dry_run: Boolean(outcome.dryRun),
163
- kind,
164
- slug: parsed.slug,
165
- source_path: outcome.sourcePath,
166
- dest_path: outcome.destPath,
167
- archived_entry_id: outcome.archivedEntryId || null,
168
- superseded_entry_id: outcome.supersededEntryId || null,
169
- start_at: outcome.startAt || null
170
- };
171
- }
172
-
173
- if (outcome.dryRun) {
174
- const msg = tFn(t, 'memory_archive.dry_run_summary', {
175
- kind,
176
- slug: parsed.slug,
177
- source: outcome.sourcePath || '(no source path)',
178
- dest: outcome.destPath,
179
- has_active: outcome.hasActiveEntry ? 'yes' : 'no'
180
- }) || `memory:archive [dry-run]: would move ${outcome.sourcePath || kind + ':' + parsed.slug} → ${outcome.destPath} (active entry: ${outcome.hasActiveEntry ? 'yes' : 'no'}).`;
181
- log(msg);
182
- } else {
183
- const msg = tFn(t, 'memory_archive.archived_success', {
184
- kind,
185
- slug: parsed.slug,
186
- dest: outcome.destPath
187
- }) || `memory:archive ✓ ${kind} "${parsed.slug}" archived to ${outcome.destPath}.`;
188
- log(msg);
189
- }
190
- return outcome;
191
- }
192
-
193
- module.exports = { runMemoryArchive };
1
+ 'use strict';
2
+
3
+ /**
4
+ * aioson memory:archive --id=<rule|learning|brain>:<slug> --reason="<text>" [--dry-run] [--json]
5
+ *
6
+ * Tier-2 human-actioned command (PMD-4 / Article VII / BR-ALL-01). Moves a
7
+ * curated artifact to `_archived/{YYYY-MM-DD}/` and records the transition in
8
+ * `evolution_log` using the validity-window pattern (BR-ALL-02 append-only):
9
+ * - supersedes the prior active entry (sets `end_at`)
10
+ * - inserts a new `event_type='archived'` row
11
+ * - for learnings: also flips `project_learnings.status` to 'archived'
12
+ *
13
+ * Refuses to run when `process.env.AIOSON_RUNTIME_HOOK === '1'` (BR-ALL-01:
14
+ * hook code paths are never permitted to archive — only direct invocation by
15
+ * a human).
16
+ *
17
+ * Emits `aioson notify --level=warn` BEFORE mutating disk or DB (BR-ALL-06).
18
+ */
19
+
20
+ const path = require('node:path');
21
+ const { openRuntimeDb } = require('../runtime-store');
22
+ const { runNotify } = require('./notify');
23
+ const {
24
+ parseTargetId,
25
+ normalizeKind,
26
+ archiveTarget,
27
+ TARGET_TYPES
28
+ } = require('../learning-loop-archive');
29
+
30
+ function tFn(t, key, params) {
31
+ if (typeof t === 'function') {
32
+ try { return t(key, params || {}); } catch { /* fall through */ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function isHookContext() {
38
+ return process.env.AIOSON_RUNTIME_HOOK === '1';
39
+ }
40
+
41
+ async function runMemoryArchive({ args, options = {}, logger, t }) {
42
+ const targetDir = path.resolve(process.cwd(), args && args[0] ? args[0] : '.');
43
+ const wantJson = Boolean(options.json);
44
+ const dryRun = Boolean(options['dry-run'] || options.dryRun);
45
+ const log = (msg) => { if (logger && typeof logger.log === 'function') logger.log(msg); };
46
+
47
+ if (isHookContext()) {
48
+ const msg = tFn(t, 'cli.memory_archive.hook_blocked')
49
+ || 'memory:archive cannot be invoked from a runtime hook (BR-ALL-01: tier-2 requires human action).';
50
+ if (wantJson) return { ok: false, reason: 'hook_blocked' };
51
+ log(msg);
52
+ return { ok: false, reason: 'hook_blocked' };
53
+ }
54
+
55
+ const rawId = options.id || options.target || '';
56
+ const reason = options.reason ? String(options.reason).trim() : '';
57
+
58
+ if (!rawId) {
59
+ const msg = tFn(t, 'cli.memory_archive.id_required')
60
+ || 'memory:archive requires --id=<rule|learning|brain>:<slug>.';
61
+ if (wantJson) return { ok: false, reason: 'missing_id' };
62
+ log(msg);
63
+ return { ok: false, reason: 'missing_id' };
64
+ }
65
+ if (!reason) {
66
+ const msg = tFn(t, 'cli.memory_archive.reason_required')
67
+ || 'memory:archive requires --reason="<text>".';
68
+ if (wantJson) return { ok: false, reason: 'missing_reason' };
69
+ log(msg);
70
+ return { ok: false, reason: 'missing_reason' };
71
+ }
72
+
73
+ const parsed = parseTargetId(rawId);
74
+ const kind = normalizeKind(parsed.kind);
75
+ if (!kind || !TARGET_TYPES.has(kind)) {
76
+ const msg = tFn(t, 'cli.memory_archive.invalid_id', { value: rawId })
77
+ || `memory:archive invalid --id value: "${rawId}". Expected rule|learning|brain:<slug>.`;
78
+ if (wantJson) return { ok: false, reason: 'invalid_id', value: rawId };
79
+ log(msg);
80
+ return { ok: false, reason: 'invalid_id' };
81
+ }
82
+ if (!parsed.slug) {
83
+ const msg = tFn(t, 'cli.memory_archive.invalid_id', { value: rawId })
84
+ || `memory:archive invalid --id value: "${rawId}". Missing slug after ":".`;
85
+ if (wantJson) return { ok: false, reason: 'invalid_id', value: rawId };
86
+ log(msg);
87
+ return { ok: false, reason: 'invalid_id' };
88
+ }
89
+
90
+ // BR-ALL-06: emit tier-2 notify BEFORE any mutation (defense in depth).
91
+ // notify.runNotify returns { ok, exitCode } — non-zero aborts.
92
+ const notifyMessage = tFn(t, 'cli.memory_archive.notify_template', { kind, slug: parsed.slug, reason })
93
+ || `archiving ${kind} "${parsed.slug}": ${reason}`;
94
+ let notifyResult;
95
+ try {
96
+ notifyResult = await runNotify({
97
+ args: [targetDir],
98
+ options: {
99
+ level: 'warn',
100
+ topic: 'memory',
101
+ message: notifyMessage,
102
+ agent: 'memory-archive',
103
+ json: wantJson ? true : undefined
104
+ },
105
+ logger: logger || { log: () => {} }
106
+ });
107
+ } catch (err) {
108
+ if (wantJson) return { ok: false, reason: 'notify_failed', error: String(err && err.message || err) };
109
+ log(`memory:archive notify failed: ${err && err.message ? err.message : err}`);
110
+ return { ok: false, reason: 'notify_failed' };
111
+ }
112
+ if (notifyResult && notifyResult.ok === false) {
113
+ if (wantJson) return { ok: false, reason: 'notify_blocked', exitCode: notifyResult.exitCode };
114
+ log('memory:archive aborted: tier-2 notify returned non-zero exit code.');
115
+ return { ok: false, reason: 'notify_blocked' };
116
+ }
117
+
118
+ let dbHandle;
119
+ try {
120
+ dbHandle = await openRuntimeDb(targetDir);
121
+ } catch (err) {
122
+ if (wantJson) return { ok: false, reason: 'runtime_db_unavailable', error: String(err && err.message || err) };
123
+ log(`memory:archive runtime db unavailable: ${err && err.message ? err.message : err}`);
124
+ return { ok: false, reason: 'runtime_db_unavailable' };
125
+ }
126
+
127
+ const { db } = dbHandle;
128
+ let outcome;
129
+ try {
130
+ outcome = archiveTarget(db, {
131
+ targetDir,
132
+ kind,
133
+ slug: parsed.slug,
134
+ reason,
135
+ actor: 'human',
136
+ featureSlug: options.feature ? String(options.feature).trim() : null,
137
+ dryRun
138
+ });
139
+ } finally {
140
+ db.close();
141
+ }
142
+
143
+ if (!outcome.ok) {
144
+ if (wantJson) return outcome;
145
+ if (outcome.reason === 'already_archived') {
146
+ const msg = tFn(t, 'cli.memory_archive.already_archived', { path: outcome.archivedAt })
147
+ || `memory:archive: "${parsed.slug}" already archived (${outcome.archivedAt || 'unknown path'}). No-op.`;
148
+ log(msg);
149
+ } else if (outcome.reason === 'target_not_found') {
150
+ const msg = tFn(t, 'cli.memory_archive.target_not_found', { kind, slug: parsed.slug })
151
+ || `memory:archive: ${kind} "${parsed.slug}" not found in active state.`;
152
+ log(msg);
153
+ } else {
154
+ log(`memory:archive failed: ${outcome.reason}${outcome.error ? ' — ' + outcome.error : ''}`);
155
+ }
156
+ return outcome;
157
+ }
158
+
159
+ if (wantJson) {
160
+ return {
161
+ ok: true,
162
+ dry_run: Boolean(outcome.dryRun),
163
+ kind,
164
+ slug: parsed.slug,
165
+ source_path: outcome.sourcePath,
166
+ dest_path: outcome.destPath,
167
+ archived_entry_id: outcome.archivedEntryId || null,
168
+ superseded_entry_id: outcome.supersededEntryId || null,
169
+ start_at: outcome.startAt || null
170
+ };
171
+ }
172
+
173
+ if (outcome.dryRun) {
174
+ const msg = tFn(t, 'cli.memory_archive.dry_run_summary', {
175
+ kind,
176
+ slug: parsed.slug,
177
+ source: outcome.sourcePath || '(no source path)',
178
+ dest: outcome.destPath,
179
+ has_active: outcome.hasActiveEntry ? 'yes' : 'no'
180
+ }) || `memory:archive [dry-run]: would move ${outcome.sourcePath || kind + ':' + parsed.slug} → ${outcome.destPath} (active entry: ${outcome.hasActiveEntry ? 'yes' : 'no'}).`;
181
+ log(msg);
182
+ } else {
183
+ const msg = tFn(t, 'cli.memory_archive.archived_success', {
184
+ kind,
185
+ slug: parsed.slug,
186
+ dest: outcome.destPath
187
+ }) || `memory:archive ✓ ${kind} "${parsed.slug}" archived to ${outcome.destPath}.`;
188
+ log(msg);
189
+ }
190
+ return outcome;
191
+ }
192
+
193
+ module.exports = { runMemoryArchive };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // aioson memory:reflect-commit [.] --agent=<name> [--output=<path-to-json>] [--json]
3
+ // aioson memory:reflect-commit [.] --agent=<name> [--output=<path-to-json>] [--json] [--dry-run]
4
4
  //
5
5
  // Reads the reflect manifest written by memory:reflect-prepare, accepts the
6
6
  // agent's reflected output (as a JSON map of relative path → new content),
@@ -63,13 +63,14 @@ async function emitEvent(targetDir, agent, type, message, payload) {
63
63
  async function runMemoryReflectCommit({ args, options = {}, logger }) {
64
64
  const targetDir = path.resolve(process.cwd(), args?.[0] || '.');
65
65
  const agent = String(options.agent || '').trim() || 'unknown';
66
+ const dryRun = Boolean(options['dry-run'] || options.dryRun);
66
67
 
67
68
  const manifestPath = path.join(targetDir, REFLECT_PROMPT_RELATIVE);
68
69
  let manifest;
69
70
  try {
70
71
  manifest = await readJsonFile(manifestPath);
71
72
  } catch {
72
- const message = `manifest not found at ${path.relative(targetDir, manifestPath)} — run memory:reflect-prepare first`;
73
+ const message = `manifest not found at ${path.relative(targetDir, manifestPath)} — it may have been consumed by a previous successful reflect-commit; run memory:reflect-prepare to generate a new one`;
73
74
  if (!options.json) logger.log(`✗ ${message}`);
74
75
  return { ok: false, error: 'missing_manifest', message };
75
76
  }
@@ -102,9 +103,11 @@ async function runMemoryReflectCommit({ args, options = {}, logger }) {
102
103
  // verify that every resolved absolute path stays under the project's
103
104
  // bootstrap directory. validate() already rejects absolute paths and
104
105
  // `..` segments, but this is the second wall in case the manifest's
105
- // allowed_paths is ever extended beyond bootstrap/.
106
+ // allowed_paths is ever extended beyond bootstrap/. Run this for BOTH
107
+ // dry-run and real commits so a dry-run validates exactly what a real
108
+ // commit would do.
106
109
  const bootstrapRoot = path.resolve(targetDir, '.aioson/context/bootstrap');
107
- const written = [];
110
+ const planned = [];
108
111
  for (const [relPath, content] of Object.entries(files)) {
109
112
  const absPath = path.resolve(targetDir, relPath);
110
113
  if (!absPath.startsWith(bootstrapRoot + path.sep) && absPath !== bootstrapRoot) {
@@ -116,6 +119,27 @@ async function runMemoryReflectCommit({ args, options = {}, logger }) {
116
119
  });
117
120
  return { ok: false, error: 'path_escape', message: msg };
118
121
  }
122
+ planned.push({ relPath, absPath, content });
123
+ }
124
+
125
+ // --dry-run: validation + path containment already passed. Report what WOULD
126
+ // be written and stop — no disk writes, and crucially the manifest is NOT
127
+ // consumed, so a real commit can still follow. A dry-run must never mutate
128
+ // state (this is the bug fix: previously --dry-run was ignored and ran the
129
+ // full destructive commit).
130
+ if (dryRun) {
131
+ const wouldWrite = planned.map((p) => p.relPath);
132
+ if (!options.json) {
133
+ logger.log('• reflect-commit DRY RUN — no files written, manifest preserved');
134
+ for (const w of wouldWrite) logger.log(` - would write: ${w}`);
135
+ }
136
+ await emitEvent(targetDir, agent, 'memory_reflect_dry_run',
137
+ `dry-run validated ${wouldWrite.length} file(s)`, { would_write: wouldWrite });
138
+ return { ok: true, dryRun: true, would_write: wouldWrite };
139
+ }
140
+
141
+ const written = [];
142
+ for (const { relPath, absPath, content } of planned) {
119
143
  // SF-project-22: scrub zero-width / bidi / HTML-comment injection carriers
120
144
  // from the LLM-authored content before it lands in bootstrap. Path
121
145
  // containment is already enforced above; this is the content layer of the