@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
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* aioson memory:search "<query>" — full-text search over project_learnings + promoted rules.
|
|
5
|
-
*
|
|
6
|
-
* Active Learning Loop Phase 2 (DD-4: BM25 default via ORDER BY rank ASC).
|
|
7
|
-
* Tier-1 silent telemetry-wise (no runtime:emit per DD-4 guardrail #8) but writes
|
|
8
|
-
* results to stdout (or JSON if --json) — this is a user-facing CLI verb, not a
|
|
9
|
-
* background hook.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* aioson memory:search "<query>" [--limit=5] [--surface=rules|learnings|all]
|
|
13
|
-
* [--include-archived] [--json]
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const path = require('node:path');
|
|
17
|
-
const { openRuntimeDb } = require('../runtime-store');
|
|
18
|
-
const { searchProjectLearnings, QUERY_MAX_CHARS } = require('../learning-loop-fts5');
|
|
19
|
-
|
|
20
|
-
function tFn(t, key, params) {
|
|
21
|
-
if (typeof t === 'function') {
|
|
22
|
-
try { return t(key, params || {}); } catch { /* fall through */ }
|
|
23
|
-
}
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function stripDelimiters(snippet) {
|
|
28
|
-
if (!snippet) return '';
|
|
29
|
-
return String(snippet).replace(/«|»/g, '');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function formatTextResults(results) {
|
|
33
|
-
const lines = [];
|
|
34
|
-
for (let i = 0; i < results.length; i++) {
|
|
35
|
-
const r = results[i];
|
|
36
|
-
lines.push(
|
|
37
|
-
`${i + 1}. [${r.target_type}] ${r.target_id} (status=${r.status}${r.feature_slug ? `, feature=${r.feature_slug}` : ''}, score=${Number(r.score).toFixed(4)})`
|
|
38
|
-
);
|
|
39
|
-
lines.push(` ${stripDelimiters(r.snippet)}`);
|
|
40
|
-
}
|
|
41
|
-
return lines.join('\n');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function runMemorySearch({ args, options = {}, logger, t }) {
|
|
45
|
-
const targetDir = path.resolve(process.cwd(), args[1] !== undefined ? args[1] : '.');
|
|
46
|
-
// Positional 0 is the query string. Some shells will pass it as args[0].
|
|
47
|
-
const query = args[0] !== undefined && args[0] !== null && String(args[0]).trim() !== ''
|
|
48
|
-
? String(args[0])
|
|
49
|
-
: (options.query ? String(options.query) : '');
|
|
50
|
-
|
|
51
|
-
const wantJson = Boolean(options.json);
|
|
52
|
-
const log = (msg) => { if (logger && typeof logger.log === 'function') logger.log(msg); };
|
|
53
|
-
|
|
54
|
-
if (!query || !query.trim()) {
|
|
55
|
-
const msg = tFn(t, 'memory_search.query_empty') || `memory:search requires a non-empty query.`;
|
|
56
|
-
if (wantJson) return { ok: false, reason: 'query_empty' };
|
|
57
|
-
log(msg);
|
|
58
|
-
return { ok: false, reason: 'query_empty' };
|
|
59
|
-
}
|
|
60
|
-
if (query.length > QUERY_MAX_CHARS) {
|
|
61
|
-
const msg = tFn(t, 'memory_search.query_too_long', { max: QUERY_MAX_CHARS })
|
|
62
|
-
|| `memory:search query exceeds ${QUERY_MAX_CHARS} chars.`;
|
|
63
|
-
if (wantJson) return { ok: false, reason: 'query_too_long', max: QUERY_MAX_CHARS };
|
|
64
|
-
log(msg);
|
|
65
|
-
return { ok: false, reason: 'query_too_long' };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const limit = options.limit !== undefined ? Number(options.limit) : 5;
|
|
69
|
-
const surface = options.surface || 'all';
|
|
70
|
-
const includeArchived = Boolean(options['include-archived'] || options.includeArchived);
|
|
71
|
-
|
|
72
|
-
let dbHandle;
|
|
73
|
-
try {
|
|
74
|
-
dbHandle = await openRuntimeDb(targetDir);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
const message = err && err.message ? err.message : String(err);
|
|
77
|
-
if (wantJson) return { ok: false, reason: 'runtime_db_unavailable', error: message };
|
|
78
|
-
log(`memory:search runtime db unavailable: ${message}`);
|
|
79
|
-
return { ok: false, reason: 'runtime_db_unavailable' };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const { db } = dbHandle;
|
|
83
|
-
let outcome;
|
|
84
|
-
try {
|
|
85
|
-
outcome = searchProjectLearnings(db, { query, limit, surface, includeArchived });
|
|
86
|
-
} finally {
|
|
87
|
-
db.close();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (!outcome.ok) {
|
|
91
|
-
if (outcome.reason === 'invalid_surface') {
|
|
92
|
-
const msg = tFn(t, 'memory_search.invalid_surface', { value: outcome.value })
|
|
93
|
-
|| `memory:search invalid --surface value: ${outcome.value}.`;
|
|
94
|
-
if (wantJson) return { ok: false, reason: 'invalid_surface', value: outcome.value };
|
|
95
|
-
log(msg);
|
|
96
|
-
return { ok: false, reason: 'invalid_surface' };
|
|
97
|
-
}
|
|
98
|
-
if (outcome.reason === 'query_unparseable') {
|
|
99
|
-
const msg = tFn(t, 'memory_search.query_unparseable', { value: outcome.value })
|
|
100
|
-
|| `memory:search query reduces to empty after sanitization: "${outcome.value}".`;
|
|
101
|
-
if (wantJson) return { ok: false, reason: 'query_unparseable', value: outcome.value };
|
|
102
|
-
log(msg);
|
|
103
|
-
return { ok: false, reason: 'query_unparseable' };
|
|
104
|
-
}
|
|
105
|
-
if (wantJson) return outcome;
|
|
106
|
-
log(`memory:search rejected: ${outcome.reason}`);
|
|
107
|
-
return outcome;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const { results } = outcome;
|
|
111
|
-
if (wantJson) {
|
|
112
|
-
return {
|
|
113
|
-
ok: true,
|
|
114
|
-
query: outcome.query,
|
|
115
|
-
surface: outcome.surface,
|
|
116
|
-
limit: outcome.limit,
|
|
117
|
-
result_count: results.length,
|
|
118
|
-
results
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (results.length === 0) {
|
|
123
|
-
const msg = tFn(t, 'memory_search.no_results', { query }) || `No matches for "${query}".`;
|
|
124
|
-
log(msg);
|
|
125
|
-
return { ok: true, result_count: 0 };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const header = tFn(t, 'memory_search.results_header', { count: results.length, query })
|
|
129
|
-
|| `Top ${results.length} hits for "${query}":`;
|
|
130
|
-
log(header);
|
|
131
|
-
log(formatTextResults(results));
|
|
132
|
-
return { ok: true, result_count: results.length };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
module.exports = { runMemorySearch };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson memory:search "<query>" — full-text search over project_learnings + promoted rules.
|
|
5
|
+
*
|
|
6
|
+
* Active Learning Loop Phase 2 (DD-4: BM25 default via ORDER BY rank ASC).
|
|
7
|
+
* Tier-1 silent telemetry-wise (no runtime:emit per DD-4 guardrail #8) but writes
|
|
8
|
+
* results to stdout (or JSON if --json) — this is a user-facing CLI verb, not a
|
|
9
|
+
* background hook.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* aioson memory:search "<query>" [--limit=5] [--surface=rules|learnings|all]
|
|
13
|
+
* [--include-archived] [--json]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { openRuntimeDb } = require('../runtime-store');
|
|
18
|
+
const { searchProjectLearnings, QUERY_MAX_CHARS } = require('../learning-loop-fts5');
|
|
19
|
+
|
|
20
|
+
function tFn(t, key, params) {
|
|
21
|
+
if (typeof t === 'function') {
|
|
22
|
+
try { return t(key, params || {}); } catch { /* fall through */ }
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripDelimiters(snippet) {
|
|
28
|
+
if (!snippet) return '';
|
|
29
|
+
return String(snippet).replace(/«|»/g, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatTextResults(results) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
for (let i = 0; i < results.length; i++) {
|
|
35
|
+
const r = results[i];
|
|
36
|
+
lines.push(
|
|
37
|
+
`${i + 1}. [${r.target_type}] ${r.target_id} (status=${r.status}${r.feature_slug ? `, feature=${r.feature_slug}` : ''}, score=${Number(r.score).toFixed(4)})`
|
|
38
|
+
);
|
|
39
|
+
lines.push(` ${stripDelimiters(r.snippet)}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runMemorySearch({ args, options = {}, logger, t }) {
|
|
45
|
+
const targetDir = path.resolve(process.cwd(), args[1] !== undefined ? args[1] : '.');
|
|
46
|
+
// Positional 0 is the query string. Some shells will pass it as args[0].
|
|
47
|
+
const query = args[0] !== undefined && args[0] !== null && String(args[0]).trim() !== ''
|
|
48
|
+
? String(args[0])
|
|
49
|
+
: (options.query ? String(options.query) : '');
|
|
50
|
+
|
|
51
|
+
const wantJson = Boolean(options.json);
|
|
52
|
+
const log = (msg) => { if (logger && typeof logger.log === 'function') logger.log(msg); };
|
|
53
|
+
|
|
54
|
+
if (!query || !query.trim()) {
|
|
55
|
+
const msg = tFn(t, 'cli.memory_search.query_empty') || `memory:search requires a non-empty query.`;
|
|
56
|
+
if (wantJson) return { ok: false, reason: 'query_empty' };
|
|
57
|
+
log(msg);
|
|
58
|
+
return { ok: false, reason: 'query_empty' };
|
|
59
|
+
}
|
|
60
|
+
if (query.length > QUERY_MAX_CHARS) {
|
|
61
|
+
const msg = tFn(t, 'cli.memory_search.query_too_long', { max: QUERY_MAX_CHARS })
|
|
62
|
+
|| `memory:search query exceeds ${QUERY_MAX_CHARS} chars.`;
|
|
63
|
+
if (wantJson) return { ok: false, reason: 'query_too_long', max: QUERY_MAX_CHARS };
|
|
64
|
+
log(msg);
|
|
65
|
+
return { ok: false, reason: 'query_too_long' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const limit = options.limit !== undefined ? Number(options.limit) : 5;
|
|
69
|
+
const surface = options.surface || 'all';
|
|
70
|
+
const includeArchived = Boolean(options['include-archived'] || options.includeArchived);
|
|
71
|
+
|
|
72
|
+
let dbHandle;
|
|
73
|
+
try {
|
|
74
|
+
dbHandle = await openRuntimeDb(targetDir);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err && err.message ? err.message : String(err);
|
|
77
|
+
if (wantJson) return { ok: false, reason: 'runtime_db_unavailable', error: message };
|
|
78
|
+
log(`memory:search runtime db unavailable: ${message}`);
|
|
79
|
+
return { ok: false, reason: 'runtime_db_unavailable' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { db } = dbHandle;
|
|
83
|
+
let outcome;
|
|
84
|
+
try {
|
|
85
|
+
outcome = searchProjectLearnings(db, { query, limit, surface, includeArchived });
|
|
86
|
+
} finally {
|
|
87
|
+
db.close();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!outcome.ok) {
|
|
91
|
+
if (outcome.reason === 'invalid_surface') {
|
|
92
|
+
const msg = tFn(t, 'cli.memory_search.invalid_surface', { value: outcome.value })
|
|
93
|
+
|| `memory:search invalid --surface value: ${outcome.value}.`;
|
|
94
|
+
if (wantJson) return { ok: false, reason: 'invalid_surface', value: outcome.value };
|
|
95
|
+
log(msg);
|
|
96
|
+
return { ok: false, reason: 'invalid_surface' };
|
|
97
|
+
}
|
|
98
|
+
if (outcome.reason === 'query_unparseable') {
|
|
99
|
+
const msg = tFn(t, 'cli.memory_search.query_unparseable', { value: outcome.value })
|
|
100
|
+
|| `memory:search query reduces to empty after sanitization: "${outcome.value}".`;
|
|
101
|
+
if (wantJson) return { ok: false, reason: 'query_unparseable', value: outcome.value };
|
|
102
|
+
log(msg);
|
|
103
|
+
return { ok: false, reason: 'query_unparseable' };
|
|
104
|
+
}
|
|
105
|
+
if (wantJson) return outcome;
|
|
106
|
+
log(`memory:search rejected: ${outcome.reason}`);
|
|
107
|
+
return outcome;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { results } = outcome;
|
|
111
|
+
if (wantJson) {
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
query: outcome.query,
|
|
115
|
+
surface: outcome.surface,
|
|
116
|
+
limit: outcome.limit,
|
|
117
|
+
result_count: results.length,
|
|
118
|
+
results
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (results.length === 0) {
|
|
123
|
+
const msg = tFn(t, 'cli.memory_search.no_results', { query }) || `No matches for "${query}".`;
|
|
124
|
+
log(msg);
|
|
125
|
+
return { ok: true, result_count: 0 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const header = tFn(t, 'cli.memory_search.results_header', { count: results.length, query })
|
|
129
|
+
|| `Top ${results.length} hits for "${query}":`;
|
|
130
|
+
log(header);
|
|
131
|
+
log(formatTextResults(results));
|
|
132
|
+
return { ok: true, result_count: results.length };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { runMemorySearch };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson memory:trim [.] [--keep=<N>] [--archive=<path>] [--dry-run] [--json]
|
|
5
|
+
*
|
|
6
|
+
* P0 of the agent-loading-contract: rolls COLD entries out of the
|
|
7
|
+
* `## What the system already has` section of bootstrap/current-state.md into a
|
|
8
|
+
* separate archive (default `bootstrap/current-state-archive.md`), keeping only
|
|
9
|
+
* the newest `--keep` entries (default 12) plus any entry tied to an in_progress
|
|
10
|
+
* feature. Frontmatter and every other section are preserved byte-for-byte, and
|
|
11
|
+
* archived entries are MOVED verbatim — never deleted.
|
|
12
|
+
*
|
|
13
|
+
* Tier-2 (memory mutation): refuses to run inside a runtime hook and emits
|
|
14
|
+
* `notify --level=warn` before any disk write. `--dry-run` mutates nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { runNotify } = require('./notify');
|
|
20
|
+
const {
|
|
21
|
+
splitCurrentState,
|
|
22
|
+
buildArchiveContent,
|
|
23
|
+
parseActiveSlugs
|
|
24
|
+
} = require('../current-state-trim');
|
|
25
|
+
|
|
26
|
+
const CURRENT_STATE_REL = '.aioson/context/bootstrap/current-state.md';
|
|
27
|
+
const DEFAULT_ARCHIVE_REL = '.aioson/context/bootstrap/current-state-archive.md';
|
|
28
|
+
const FEATURES_REL = '.aioson/context/features.md';
|
|
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
|
+
function readFileOrNull(p) {
|
|
42
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseKeep(value) {
|
|
46
|
+
if (value === undefined || value === null || value === '') return 12;
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
if (!Number.isFinite(n) || n < 0) return 12;
|
|
49
|
+
return Math.floor(n);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runMemoryTrim({ args, options = {}, logger, t }) {
|
|
53
|
+
const targetDir = path.resolve(process.cwd(), args && args[0] ? args[0] : '.');
|
|
54
|
+
const wantJson = Boolean(options.json);
|
|
55
|
+
const dryRun = Boolean(options['dry-run'] || options.dryRun);
|
|
56
|
+
const keep = parseKeep(options.keep);
|
|
57
|
+
const log = (msg) => { if (logger && typeof logger.log === 'function') logger.log(msg); };
|
|
58
|
+
|
|
59
|
+
if (isHookContext()) {
|
|
60
|
+
if (wantJson) return { ok: false, reason: 'hook_blocked' };
|
|
61
|
+
log(tFn(t, 'cli.memory_trim.hook_blocked')
|
|
62
|
+
|| 'memory:trim cannot be invoked from a runtime hook (tier-2 requires human action).');
|
|
63
|
+
return { ok: false, reason: 'hook_blocked' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const currentStatePath = path.join(targetDir, CURRENT_STATE_REL);
|
|
67
|
+
let archivePath;
|
|
68
|
+
if (options.archive) {
|
|
69
|
+
// SECURITY (TS-LC-01): contain --archive under the project root. Resolve
|
|
70
|
+
// relative to the project (not cwd) and reject absolute escapes / `..`
|
|
71
|
+
// traversal, so the command can never write/overwrite a file outside the
|
|
72
|
+
// project — mirrors the containment wall in memory-reflect-commit.js.
|
|
73
|
+
const root = path.resolve(targetDir);
|
|
74
|
+
const resolved = path.resolve(root, String(options.archive));
|
|
75
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
76
|
+
if (wantJson) return { ok: false, reason: 'archive_path_escape' };
|
|
77
|
+
log(tFn(t, 'cli.memory_trim.archive_path_escape', { path: String(options.archive) })
|
|
78
|
+
|| `memory:trim: refused --archive outside the project: ${options.archive}`);
|
|
79
|
+
return { ok: false, reason: 'archive_path_escape' };
|
|
80
|
+
}
|
|
81
|
+
archivePath = resolved;
|
|
82
|
+
} else {
|
|
83
|
+
archivePath = path.join(targetDir, DEFAULT_ARCHIVE_REL);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const content = readFileOrNull(currentStatePath);
|
|
87
|
+
if (content === null) {
|
|
88
|
+
if (wantJson) return { ok: false, reason: 'no_current_state' };
|
|
89
|
+
log(tFn(t, 'cli.memory_trim.no_current_state', { path: CURRENT_STATE_REL })
|
|
90
|
+
|| `memory:trim: ${CURRENT_STATE_REL} not found (nothing to trim).`);
|
|
91
|
+
return { ok: false, reason: 'no_current_state' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const activeSlugs = parseActiveSlugs(readFileOrNull(path.join(targetDir, FEATURES_REL)) || '');
|
|
95
|
+
const result = splitCurrentState(content, { keep, activeSlugs });
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
if (wantJson) return { ok: false, reason: result.reason };
|
|
98
|
+
log(tFn(t, 'cli.memory_trim.section_not_found')
|
|
99
|
+
|| `memory:trim: "## What the system already has" section not found — nothing to do.`);
|
|
100
|
+
return { ok: false, reason: result.reason };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { hotContent, archivedEntries, stats } = result;
|
|
104
|
+
|
|
105
|
+
if (archivedEntries.length === 0) {
|
|
106
|
+
if (wantJson) return { ok: true, dry_run: dryRun, archived: 0, stats };
|
|
107
|
+
log(tFn(t, 'cli.memory_trim.nothing_to_archive', { kept: stats.kept, keep })
|
|
108
|
+
|| `memory:trim: ${stats.kept} entr${stats.kept === 1 ? 'y' : 'ies'} within keep=${keep} window — nothing to archive.`);
|
|
109
|
+
return { ok: true, dry_run: dryRun, archived: 0, stats };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (dryRun) {
|
|
113
|
+
if (!wantJson) {
|
|
114
|
+
log(tFn(t, 'cli.memory_trim.dry_run_summary', {
|
|
115
|
+
archived: stats.archived,
|
|
116
|
+
kept: stats.kept,
|
|
117
|
+
total: stats.total_entries,
|
|
118
|
+
keep,
|
|
119
|
+
saved_kb: (stats.saved_bytes / 1024).toFixed(1),
|
|
120
|
+
before_kb: (stats.before_bytes / 1024).toFixed(1),
|
|
121
|
+
after_kb: (stats.after_bytes / 1024).toFixed(1)
|
|
122
|
+
}) || `memory:trim [dry-run]: would archive ${stats.archived}/${stats.total_entries} entries (keep=${keep}, active-slug exempt). ${(stats.before_bytes / 1024).toFixed(1)}KB → ${(stats.after_bytes / 1024).toFixed(1)}KB (saves ${(stats.saved_bytes / 1024).toFixed(1)}KB). No files written.`);
|
|
123
|
+
for (const e of archivedEntries.slice(0, 5)) {
|
|
124
|
+
log(` - would archive: ${e.slice(0, 100)}${e.length > 100 ? '…' : ''}`);
|
|
125
|
+
}
|
|
126
|
+
if (archivedEntries.length > 5) log(` … and ${archivedEntries.length - 5} more`);
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, dry_run: true, archived: stats.archived, stats, archive_path: path.relative(targetDir, archivePath) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Real run — tier-2 notify before any mutation (BR-ALL-06).
|
|
132
|
+
try {
|
|
133
|
+
const notifyResult = await runNotify({
|
|
134
|
+
args: [targetDir],
|
|
135
|
+
options: {
|
|
136
|
+
level: 'warn',
|
|
137
|
+
topic: 'memory',
|
|
138
|
+
message: tFn(t, 'cli.memory_trim.notify_template', { archived: stats.archived })
|
|
139
|
+
|| `trimming current-state.md: archiving ${stats.archived} cold entries`,
|
|
140
|
+
agent: 'memory-trim',
|
|
141
|
+
json: wantJson ? true : undefined
|
|
142
|
+
},
|
|
143
|
+
logger: logger || { log: () => {} }
|
|
144
|
+
});
|
|
145
|
+
if (notifyResult && notifyResult.ok === false) {
|
|
146
|
+
if (wantJson) return { ok: false, reason: 'notify_blocked', exitCode: notifyResult.exitCode };
|
|
147
|
+
log('memory:trim aborted: tier-2 notify returned non-zero exit code.');
|
|
148
|
+
return { ok: false, reason: 'notify_blocked' };
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (wantJson) return { ok: false, reason: 'notify_failed', error: String((err && err.message) || err) };
|
|
152
|
+
log(`memory:trim notify failed: ${(err && err.message) || err}`);
|
|
153
|
+
return { ok: false, reason: 'notify_failed' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const nowIso = new Date().toISOString();
|
|
157
|
+
const eol = /\r\n/.test(content) ? '\r\n' : '\n';
|
|
158
|
+
const existingArchive = readFileOrNull(archivePath) || '';
|
|
159
|
+
const newArchive = buildArchiveContent(existingArchive, archivedEntries, nowIso, eol);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
|
163
|
+
fs.writeFileSync(archivePath, newArchive, 'utf8');
|
|
164
|
+
fs.writeFileSync(currentStatePath, hotContent, 'utf8');
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (wantJson) return { ok: false, reason: 'write_failed', error: String((err && err.message) || err) };
|
|
167
|
+
log(`memory:trim write failed: ${(err && err.message) || err}`);
|
|
168
|
+
return { ok: false, reason: 'write_failed' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (wantJson) {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
dry_run: false,
|
|
175
|
+
archived: stats.archived,
|
|
176
|
+
stats,
|
|
177
|
+
current_state_path: path.relative(targetDir, currentStatePath),
|
|
178
|
+
archive_path: path.relative(targetDir, archivePath)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
log(tFn(t, 'cli.memory_trim.trimmed_success', {
|
|
182
|
+
archived: stats.archived,
|
|
183
|
+
kept: stats.kept,
|
|
184
|
+
before_kb: (stats.before_bytes / 1024).toFixed(1),
|
|
185
|
+
after_kb: (stats.after_bytes / 1024).toFixed(1),
|
|
186
|
+
archive: path.relative(targetDir, archivePath)
|
|
187
|
+
}) || `memory:trim ✓ archived ${stats.archived} entries (kept ${stats.kept}). ${(stats.before_bytes / 1024).toFixed(1)}KB → ${(stats.after_bytes / 1024).toFixed(1)}KB. Archive: ${path.relative(targetDir, archivePath)}`);
|
|
188
|
+
return { ok: true, dry_run: false, archived: stats.archived, stats };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { runMemoryTrim };
|
package/src/constants.js
CHANGED
|
@@ -68,6 +68,7 @@ const MANAGED_FILES = [
|
|
|
68
68
|
'.aioson/docs/sheldon/harness-contract.md',
|
|
69
69
|
'.aioson/docs/dev/stack-conventions.md',
|
|
70
70
|
'.aioson/docs/dev/execution-discipline.md',
|
|
71
|
+
'.aioson/docs/quality/code-health-analysis.md',
|
|
71
72
|
'.aioson/skills/process/decision-presentation/SKILL.md',
|
|
72
73
|
'.aioson/skills/process/decision-presentation/references/jargon-map.en.yaml',
|
|
73
74
|
'.aioson/skills/process/decision-presentation/references/jargon-map.pt-BR.yaml',
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure engine for trimming bootstrap/current-state.md (P0 of the
|
|
4
|
+
// agent-loading-contract design-doc).
|
|
5
|
+
//
|
|
6
|
+
// The "## What the system already has" section is an append-only log that grows
|
|
7
|
+
// unbounded (newest entries prepended). Every implementation/continuity/review
|
|
8
|
+
// agent reads the whole file at activation, so this single section dominates the
|
|
9
|
+
// per-activation token cost.
|
|
10
|
+
//
|
|
11
|
+
// splitCurrentState() moves COLD entries (older than the keep window AND not tied
|
|
12
|
+
// to an active feature) out of that ONE section into a separate archive, keeping
|
|
13
|
+
// frontmatter and every OTHER section byte-for-byte. Nothing is ever deleted —
|
|
14
|
+
// archived entries are preserved verbatim and stay grep/`memory:search`-able.
|
|
15
|
+
|
|
16
|
+
const HOT_SECTION = '## What the system already has';
|
|
17
|
+
|
|
18
|
+
function detectEol(content) {
|
|
19
|
+
return /\r\n/.test(String(content || '')) ? '\r\n' : '\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function splitLines(content) {
|
|
23
|
+
return String(content || '').split(/\r?\n/);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isEntry(line) {
|
|
27
|
+
return /^- /.test(line);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Locate the hot-log section: its header line index and the index of the next
|
|
31
|
+
// "## " header (or EOF). Returns null when the section is absent.
|
|
32
|
+
function locateSection(lines) {
|
|
33
|
+
const headerIdx = lines.findIndex((l) => l.trim() === HOT_SECTION);
|
|
34
|
+
if (headerIdx === -1) return null;
|
|
35
|
+
let endIdx = lines.length;
|
|
36
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
37
|
+
if (/^##\s/.test(lines[i])) { endIdx = i; break; }
|
|
38
|
+
}
|
|
39
|
+
return { headerIdx, endIdx };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function entryMatchesActiveSlug(entryText, activeSlugs) {
|
|
43
|
+
return activeSlugs.some((s) => s && entryText.includes(s));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split current-state.md into hot content + the entries that should be archived.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} content raw current-state.md
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {number} [opts.keep=12] number of newest entries to keep HOT
|
|
52
|
+
* @param {string[]} [opts.activeSlugs=[]] feature slugs whose entries are kept regardless of age
|
|
53
|
+
* @returns {{ok:boolean, reason?:string, hotContent?:string, archivedEntries?:string[], stats?:object}}
|
|
54
|
+
*/
|
|
55
|
+
function splitCurrentState(content, { keep = 12, activeSlugs = [] } = {}) {
|
|
56
|
+
const eol = detectEol(content);
|
|
57
|
+
const lines = splitLines(content);
|
|
58
|
+
const loc = locateSection(lines);
|
|
59
|
+
if (!loc) return { ok: false, reason: 'section_not_found' };
|
|
60
|
+
|
|
61
|
+
const { headerIdx, endIdx } = loc;
|
|
62
|
+
const sectionBody = lines.slice(headerIdx + 1, endIdx);
|
|
63
|
+
const entries = sectionBody.filter(isEntry);
|
|
64
|
+
const totalEntries = entries.length;
|
|
65
|
+
|
|
66
|
+
const kept = [];
|
|
67
|
+
const archivedEntries = [];
|
|
68
|
+
entries.forEach((entry, idx) => {
|
|
69
|
+
const recent = idx < keep; // newest-first: top N stay
|
|
70
|
+
const active = entryMatchesActiveSlug(entry, activeSlugs); // never archive active-feature history
|
|
71
|
+
if (recent || active) kept.push(entry);
|
|
72
|
+
else archivedEntries.push(entry);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Section preamble = non-entry lines before the first bullet (the intro line).
|
|
76
|
+
const firstEntryOffset = sectionBody.findIndex(isEntry);
|
|
77
|
+
const preamble = firstEntryOffset === -1 ? sectionBody : sectionBody.slice(0, firstEntryOffset);
|
|
78
|
+
const introLines = preamble.filter((l) => l.trim() !== '');
|
|
79
|
+
const rest = lines.slice(endIdx); // next "## " section onward, kept verbatim
|
|
80
|
+
|
|
81
|
+
const hotLines = [
|
|
82
|
+
...lines.slice(0, headerIdx + 1), // frontmatter … through the section header
|
|
83
|
+
'',
|
|
84
|
+
...introLines,
|
|
85
|
+
'',
|
|
86
|
+
...kept,
|
|
87
|
+
'',
|
|
88
|
+
...rest
|
|
89
|
+
];
|
|
90
|
+
const hotContent = hotLines.join(eol);
|
|
91
|
+
|
|
92
|
+
const beforeBytes = Buffer.byteLength(content, 'utf8');
|
|
93
|
+
const afterBytes = Buffer.byteLength(hotContent, 'utf8');
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
hotContent,
|
|
97
|
+
archivedEntries,
|
|
98
|
+
stats: {
|
|
99
|
+
total_entries: totalEntries,
|
|
100
|
+
kept: kept.length,
|
|
101
|
+
archived: archivedEntries.length,
|
|
102
|
+
keep,
|
|
103
|
+
active_slugs: activeSlugs,
|
|
104
|
+
before_bytes: beforeBytes,
|
|
105
|
+
after_bytes: afterBytes,
|
|
106
|
+
saved_bytes: Math.max(0, beforeBytes - afterBytes)
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ARCHIVE_HEADER = '## Archived capabilities';
|
|
112
|
+
|
|
113
|
+
function buildArchiveContent(existing, newEntries, nowIso, eol = '\n') {
|
|
114
|
+
if (!newEntries.length) return existing || '';
|
|
115
|
+
if (!existing || !existing.trim()) {
|
|
116
|
+
return [
|
|
117
|
+
'---',
|
|
118
|
+
'generated_by: memory-trim',
|
|
119
|
+
`updated_at: "${nowIso}"`,
|
|
120
|
+
'---',
|
|
121
|
+
'',
|
|
122
|
+
'# Current State — Archive',
|
|
123
|
+
'',
|
|
124
|
+
'> Cold storage for `current-state.md` entries rolled off the hot log by `aioson memory:trim`.',
|
|
125
|
+
'> Searchable (`memory:search` / grep); never loaded at agent activation. Append-only — never deleted.',
|
|
126
|
+
'',
|
|
127
|
+
ARCHIVE_HEADER,
|
|
128
|
+
'',
|
|
129
|
+
...newEntries,
|
|
130
|
+
''
|
|
131
|
+
].join(eol);
|
|
132
|
+
}
|
|
133
|
+
// Prepend the new batch right after the archive header (newest-first), and
|
|
134
|
+
// bump updated_at when present.
|
|
135
|
+
const lines = existing.split(/\r?\n/);
|
|
136
|
+
const headerIdx = lines.findIndex((l) => l.trim() === ARCHIVE_HEADER);
|
|
137
|
+
let bumped = lines.map((l) =>
|
|
138
|
+
/^updated_at\s*:/.test(l) ? `updated_at: "${nowIso}"` : l
|
|
139
|
+
);
|
|
140
|
+
if (headerIdx === -1) {
|
|
141
|
+
return [...bumped, '', ...newEntries, ''].join(eol);
|
|
142
|
+
}
|
|
143
|
+
const insertAt = headerIdx + 1;
|
|
144
|
+
const head = bumped.slice(0, insertAt);
|
|
145
|
+
const tail = bumped.slice(insertAt);
|
|
146
|
+
return [...head, '', ...newEntries, ...tail].join(eol);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse in_progress feature slugs from a features.md pipe table.
|
|
151
|
+
* @returns {string[]}
|
|
152
|
+
*/
|
|
153
|
+
function parseActiveSlugs(featuresMd) {
|
|
154
|
+
return splitLines(featuresMd)
|
|
155
|
+
.map((l) => l.split('|').map((c) => c.trim()))
|
|
156
|
+
.filter((cols) => cols.length >= 4 && cols[2] === 'in_progress')
|
|
157
|
+
.map((cols) => cols[1])
|
|
158
|
+
.filter((slug) => slug && slug !== 'slug' && !/^-+$/.test(slug));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
HOT_SECTION,
|
|
163
|
+
ARCHIVE_HEADER,
|
|
164
|
+
splitCurrentState,
|
|
165
|
+
buildArchiveContent,
|
|
166
|
+
parseActiveSlugs,
|
|
167
|
+
// exported for tests
|
|
168
|
+
locateSection,
|
|
169
|
+
detectEol
|
|
170
|
+
};
|
package/src/doctor.js
CHANGED
|
@@ -241,6 +241,23 @@ async function runDoctor(targetDir) {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Gemini CLI deprecation advisory (gemini-phaseout Phase 1 / v1.21.0).
|
|
245
|
+
// Emits ONLY when the project actually uses Gemini (.gemini/permissions.toml
|
|
246
|
+
// OR .gemini/GEMINI.md present) so greenfield projects stay silent (BR-GP-03).
|
|
247
|
+
const geminiInUse =
|
|
248
|
+
(await exists(path.join(targetDir, '.gemini/permissions.toml'))) ||
|
|
249
|
+
(await exists(path.join(targetDir, '.gemini/GEMINI.md')));
|
|
250
|
+
if (geminiInUse) {
|
|
251
|
+
checks.push({
|
|
252
|
+
id: 'harness:gemini_deprecation',
|
|
253
|
+
severity: 'warning',
|
|
254
|
+
key: 'doctor.gemini_deprecation',
|
|
255
|
+
params: {},
|
|
256
|
+
ok: false,
|
|
257
|
+
hintKey: 'doctor.gemini_deprecation_hint'
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
244
261
|
const contextPath = path.join(targetDir, '.aioson/context/project.context.md');
|
|
245
262
|
checks.push({
|
|
246
263
|
id: 'context:project',
|