@jaimevalasek/aioson 1.20.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 +38 -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/install.js +5 -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/doctor.js +17 -0
- package/src/i18n/messages/en.js +16 -0
- package/src/i18n/messages/es.js +16 -0
- package/src/i18n/messages/fr.js +16 -0
- package/src/i18n/messages/pt-BR.js +16 -0
- package/src/install-wizard.js +3 -2
- package/src/lib/tool-capabilities.js +67 -64
- package/src/memory-reflect-engine.js +10 -4
- package/src/permissions-generator.js +3 -0
- 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,44 @@ 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
|
+
|
|
32
|
+
## [1.21.0] - 2026-06-XX
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **Gemini CLI deprecation warning (gemini-phaseout Phase 1).** Google announced (2026-05-20) that the Gemini CLI free/personal tier ends 2026-06-18.
|
|
36
|
+
- `install-wizard` now flags Gemini as `[DEPRECATED]` in the tool list and prints a post-selection notice when Gemini is chosen.
|
|
37
|
+
- `doctor` reports `harness:gemini_deprecation` (warning) when `.gemini/permissions.toml` or `.gemini/GEMINI.md` is detected — zero output on projects without `.gemini/`.
|
|
38
|
+
- `permissions-generator` continues to emit `.gemini/permissions.toml` with a header warning (enterprise unaffected).
|
|
39
|
+
- `tool-capabilities` Gemini entry annotated as deprecated.
|
|
40
|
+
- Warning strings localized in all 4 locales (en, pt-BR, es, fr).
|
|
41
|
+
- Enterprise users (Code Assist Standard/Enterprise) are unaffected.
|
|
42
|
+
- Hard removal scheduled for v1.22 (post 2026-06-18). Pre-existing `.gemini/permissions.toml` will be preserved.
|
|
43
|
+
- Recommended migration: `--tool=codex` or `--tool=opencode`.
|
|
44
|
+
|
|
7
45
|
## [1.18.0] - 2026-05-27
|
|
8
46
|
|
|
9
47
|
### 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,
|
package/src/commands/install.js
CHANGED
|
@@ -147,6 +147,11 @@ async function runInstall({ args, options, logger, t }) {
|
|
|
147
147
|
logger.log(t('install.existing_project_scan_hint'));
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
if (installProfile && Array.isArray(installProfile.tools) && installProfile.tools.includes('gemini')) {
|
|
151
|
+
logger.log('');
|
|
152
|
+
logger.log(t('install.gemini_deprecation_notice'));
|
|
153
|
+
}
|
|
154
|
+
|
|
150
155
|
return {
|
|
151
156
|
ok: true,
|
|
152
157
|
targetDir,
|
|
@@ -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 };
|