@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 +25 -0
- package/docs/pt/living-memory/reflexao-in-harness.md +2 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/commands/context-health.js +35 -2
- package/src/commands/feature-close.js +36 -0
- package/src/commands/memory-archive.js +193 -193
- package/src/commands/memory-reflect-commit.js +28 -4
- package/src/commands/memory-restore.js +177 -177
- package/src/commands/memory-search.js +135 -135
- package/src/commands/memory-trim.js +191 -0
- package/src/constants.js +1 -0
- package/src/current-state-trim.js +170 -0
- package/src/i18n/messages/en.js +12 -0
- package/src/i18n/messages/es.js +12 -0
- package/src/i18n/messages/fr.js +12 -0
- package/src/i18n/messages/pt-BR.js +12 -0
- package/src/memory-reflect-engine.js +10 -4
- package/template/.aioson/agents/architect.md +3 -0
- package/template/.aioson/agents/committer.md +1 -1
- package/template/.aioson/agents/dev.md +2 -2
- package/template/.aioson/agents/deyvin.md +2 -1
- package/template/.aioson/agents/pentester.md +1 -0
- package/template/.aioson/agents/qa.md +4 -0
- package/template/.aioson/agents/sheldon.md +1 -0
- package/template/.aioson/agents/tester.md +2 -0
- package/template/.aioson/config/autonomy-protocol.json +1 -0
- package/template/.aioson/design-docs/agent-loading-contract.md +138 -0
- package/template/.aioson/docs/quality/code-health-analysis.md +79 -0
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
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
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
|