@jaimevalasek/aioson 1.9.2 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +206 -0
- package/README.md +44 -1
- package/package.json +1 -1
- package/src/cli.js +45 -1
- package/src/commands/op-capture.js +146 -0
- package/src/commands/op-forget.js +54 -0
- package/src/commands/op-identity.js +145 -0
- package/src/commands/op-list.js +105 -0
- package/src/commands/op-migrate.js +158 -0
- package/src/commands/op-promote.js +66 -0
- package/src/commands/op-reinforce.js +73 -0
- package/src/commands/op-show.js +71 -0
- package/src/commands/op-stubs.js +67 -0
- package/src/commands/preflight.js +6 -2
- package/src/commands/runtime.js +151 -0
- package/src/commands/state-save.js +61 -0
- package/src/commands/sync-agents-preflight.js +117 -3
- package/src/commands/workflow-next.js +64 -0
- package/src/handoff-contract.js +25 -0
- package/src/lib/agent-semantic-diff.js +199 -0
- package/src/operator-memory/conflict.js +202 -0
- package/src/operator-memory/decay.js +157 -0
- package/src/operator-memory/decision.js +274 -0
- package/src/operator-memory/identity.js +109 -0
- package/src/operator-memory/index-md.js +170 -0
- package/src/operator-memory/loader.js +106 -0
- package/src/operator-memory/proposal.js +179 -0
- package/src/operator-memory/prune.js +81 -0
- package/src/operator-memory/slug.js +90 -0
- package/src/operator-memory/storage.js +121 -0
- package/src/preflight-engine.js +91 -1
- package/template/.aioson/agents/dev.md +1 -1
- package/template/.aioson/agents/deyvin.md +3 -3
- package/template/.aioson/agents/manifests/pm.manifest.json +2 -1
- package/template/.aioson/agents/neo.md +1 -1
- package/template/.aioson/agents/orchestrator.md +4 -3
- package/template/.aioson/agents/pm.md +58 -6
- package/template/.aioson/agents/product.md +1 -1
- package/template/.aioson/agents/setup.md +1 -1
- package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
- package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +2 -2
- package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
- package/template/AGENTS.md +23 -0
- package/template/CLAUDE.md +23 -0
- package/template/agents/_shared/memory-capture-directive.md +115 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — decision CRUD with atomic promote (Phase 2, v1.13.0).
|
|
5
|
+
*
|
|
6
|
+
* Atomicity boundary (AC-P2-03): SQLite transaction wraps fs operations.
|
|
7
|
+
* Crash mid-transaction → rollback. Markdown is source-of-truth (PMD-AN-06);
|
|
8
|
+
* FTS5 mirror is regenerable. See architecture-operator-memory.md § Phase 2.
|
|
9
|
+
*
|
|
10
|
+
* Schema (frontmatter + body):
|
|
11
|
+
* ---
|
|
12
|
+
* slug: ...
|
|
13
|
+
* signal_type: ...
|
|
14
|
+
* promoted_at: <ISO>
|
|
15
|
+
* last_reinforced: <ISO>
|
|
16
|
+
* reinforcement_count: 0
|
|
17
|
+
* superseded_by: null
|
|
18
|
+
* category: identity | autonomy | tooling | default
|
|
19
|
+
* source_agent: ...
|
|
20
|
+
* quotes: [...] # capped at 5
|
|
21
|
+
* version_schema: "1.0"
|
|
22
|
+
* deprecated_by: null
|
|
23
|
+
* ---
|
|
24
|
+
*
|
|
25
|
+
* # {Title}
|
|
26
|
+
*
|
|
27
|
+
* {Body — short paragraph}
|
|
28
|
+
*
|
|
29
|
+
* ## Trigger quotes
|
|
30
|
+
* - "..."
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { getStorageRoot, openIndexDb } = require('./storage');
|
|
36
|
+
const { deleteProposal, proposalPath } = require('./proposal');
|
|
37
|
+
|
|
38
|
+
const SCHEMA_VERSION = '1.0';
|
|
39
|
+
const MAX_BODY_CHARS = 500;
|
|
40
|
+
const VALID_CATEGORIES = ['identity', 'autonomy', 'tooling', 'default'];
|
|
41
|
+
|
|
42
|
+
const CATEGORY_KEYWORDS = {
|
|
43
|
+
identity: ['preferencia', 'preference', 'estilo', 'style', 'communication', 'comunicacao', 'linguagem', 'language', 'tom', 'tone'],
|
|
44
|
+
autonomy: ['commit', 'push', 'publish', 'deploy', 'merge', 'release', 'tag'],
|
|
45
|
+
tooling: ['cli', 'tool', 'comando', 'command', 'aws', 'gcp', 'kubectl', 'docker', 'npm', 'pip']
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function inferCategory(signalType, body) {
|
|
49
|
+
if (signalType !== 'authorization') return 'default';
|
|
50
|
+
const text = String(body || '').toLowerCase();
|
|
51
|
+
for (const cat of ['identity', 'autonomy', 'tooling']) {
|
|
52
|
+
if (CATEGORY_KEYWORDS[cat].some((kw) => text.includes(kw))) return cat;
|
|
53
|
+
}
|
|
54
|
+
return 'default';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decisionPath(identity, slug) {
|
|
58
|
+
return path.join(getStorageRoot(identity), 'decisions', `${slug}.md`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function historyPath(identity, slug, isoStamp) {
|
|
62
|
+
return path.join(getStorageRoot(identity), 'history', `${isoStamp.replace(/[:.]/g, '-')}-${slug}.md`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeYamlString(value) {
|
|
66
|
+
const s = String(value || '');
|
|
67
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function quotesToYaml(quotes) {
|
|
71
|
+
if (!quotes || quotes.length === 0) return '[]';
|
|
72
|
+
return '\n' + quotes.map((q) => ` - ${escapeYamlString(q)}`).join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function deriveTitle(proposal) {
|
|
76
|
+
const text = String(proposal || '').trim();
|
|
77
|
+
if (!text) return 'Untitled decision';
|
|
78
|
+
return text.charAt(0).toUpperCase() + text.slice(1, 100);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serializeDecision(data) {
|
|
82
|
+
const body = String(data.body || data.proposal || '').slice(0, MAX_BODY_CHARS);
|
|
83
|
+
const title = deriveTitle(data.title || data.proposal);
|
|
84
|
+
return [
|
|
85
|
+
'---',
|
|
86
|
+
`slug: ${data.slug}`,
|
|
87
|
+
`signal_type: ${data.signal_type}`,
|
|
88
|
+
`promoted_at: ${data.promoted_at}`,
|
|
89
|
+
`last_reinforced: ${data.last_reinforced}`,
|
|
90
|
+
`reinforcement_count: ${data.reinforcement_count}`,
|
|
91
|
+
`superseded_by: ${data.superseded_by ?? 'null'}`,
|
|
92
|
+
`category: ${data.category}`,
|
|
93
|
+
`source_agent: ${data.source_agent}`,
|
|
94
|
+
`quotes:${quotesToYaml(data.quotes)}`,
|
|
95
|
+
`version_schema: "${SCHEMA_VERSION}"`,
|
|
96
|
+
`deprecated_by: ${data.deprecated_by ?? 'null'}`,
|
|
97
|
+
'---',
|
|
98
|
+
'',
|
|
99
|
+
`# ${title}`,
|
|
100
|
+
'',
|
|
101
|
+
body,
|
|
102
|
+
'',
|
|
103
|
+
'## Trigger quotes',
|
|
104
|
+
...(data.quotes || []).map((q) => `- "${q}"`),
|
|
105
|
+
''
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseDecisionFile(content) {
|
|
110
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
111
|
+
if (!fmMatch) return null;
|
|
112
|
+
const body = content.slice(fmMatch[0].length).trim();
|
|
113
|
+
const out = { body };
|
|
114
|
+
let inQuotes = false;
|
|
115
|
+
let quotes = [];
|
|
116
|
+
for (const rawLine of fmMatch[1].split('\n')) {
|
|
117
|
+
if (rawLine.startsWith('quotes:')) {
|
|
118
|
+
const after = rawLine.slice('quotes:'.length).trim();
|
|
119
|
+
if (after === '[]') { inQuotes = false; out.quotes = []; continue; }
|
|
120
|
+
if (after === '') { inQuotes = true; quotes = []; continue; }
|
|
121
|
+
inQuotes = true; quotes = []; continue;
|
|
122
|
+
}
|
|
123
|
+
if (inQuotes) {
|
|
124
|
+
const m = rawLine.match(/^\s+-\s+'?([\s\S]*?)'?\s*$/);
|
|
125
|
+
if (m) {
|
|
126
|
+
quotes.push(m[1].replace(/''/g, "'"));
|
|
127
|
+
continue;
|
|
128
|
+
} else {
|
|
129
|
+
inQuotes = false;
|
|
130
|
+
out.quotes = quotes;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const fieldMatch = rawLine.match(/^([a-z_]+):\s*(.*)$/);
|
|
134
|
+
if (fieldMatch) {
|
|
135
|
+
const [, key, rawValue] = fieldMatch;
|
|
136
|
+
let value = rawValue.trim();
|
|
137
|
+
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1).replace(/''/g, "'");
|
|
138
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
139
|
+
if (value === 'null') value = null;
|
|
140
|
+
else if (/^\d+$/.test(value)) value = Number(value);
|
|
141
|
+
out[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (inQuotes) out.quotes = quotes;
|
|
145
|
+
if (!out.quotes) out.quotes = [];
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readDecision(identity, slug) {
|
|
150
|
+
const filePath = decisionPath(identity, slug);
|
|
151
|
+
if (!fs.existsSync(filePath)) return null;
|
|
152
|
+
return parseDecisionFile(fs.readFileSync(filePath, 'utf8'));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Atomically promote a proposal to a decision.
|
|
157
|
+
*
|
|
158
|
+
* SQLite transaction wraps:
|
|
159
|
+
* 1. INSERT INTO decisions_fts
|
|
160
|
+
* 2. write decisions/{slug}.md (atomic via tmp+rename)
|
|
161
|
+
* 3. delete proposals/{slug}.md
|
|
162
|
+
*
|
|
163
|
+
* Returns the decision data on success. Throws on transaction failure.
|
|
164
|
+
*/
|
|
165
|
+
function promoteProposal({ identity, proposal: proposalData }) {
|
|
166
|
+
const now = new Date().toISOString();
|
|
167
|
+
const body = String(proposalData.proposal || '').slice(0, MAX_BODY_CHARS);
|
|
168
|
+
const category = inferCategory(proposalData.signal_type, body);
|
|
169
|
+
const decision = {
|
|
170
|
+
slug: proposalData.slug,
|
|
171
|
+
signal_type: proposalData.signal_type,
|
|
172
|
+
promoted_at: now,
|
|
173
|
+
last_reinforced: now,
|
|
174
|
+
reinforcement_count: 0,
|
|
175
|
+
superseded_by: null,
|
|
176
|
+
category,
|
|
177
|
+
source_agent: proposalData.source_agent || 'unknown',
|
|
178
|
+
quotes: proposalData.quotes || [],
|
|
179
|
+
body,
|
|
180
|
+
title: proposalData.proposal,
|
|
181
|
+
deprecated_by: null
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const decFilePath = decisionPath(identity, decision.slug);
|
|
185
|
+
const decTmpPath = `${decFilePath}.tmp`;
|
|
186
|
+
const propPath = proposalPath(identity, decision.slug);
|
|
187
|
+
|
|
188
|
+
const db = openIndexDb();
|
|
189
|
+
try {
|
|
190
|
+
const txn = db.transaction(() => {
|
|
191
|
+
db.prepare(`
|
|
192
|
+
INSERT INTO decisions_fts (identity, slug, signal_type, category, body, last_reinforced)
|
|
193
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
194
|
+
`).run(identity, decision.slug, decision.signal_type, decision.category, decision.body, decision.last_reinforced);
|
|
195
|
+
|
|
196
|
+
fs.writeFileSync(decTmpPath, serializeDecision(decision), 'utf8');
|
|
197
|
+
fs.renameSync(decTmpPath, decFilePath);
|
|
198
|
+
|
|
199
|
+
if (fs.existsSync(propPath)) {
|
|
200
|
+
fs.unlinkSync(propPath);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
txn();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// Cleanup tmp on rollback
|
|
206
|
+
try { if (fs.existsSync(decTmpPath)) fs.unlinkSync(decTmpPath); } catch { /* ignore */ }
|
|
207
|
+
throw err;
|
|
208
|
+
} finally {
|
|
209
|
+
db.close();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Post-commit: regenerate MEMORY.md index (best-effort, outside transaction
|
|
213
|
+
// since MEMORY.md is regenerable from decisions/ — markdown source-of-truth).
|
|
214
|
+
try {
|
|
215
|
+
const { regenerateIndex } = require('./index-md');
|
|
216
|
+
regenerateIndex(identity);
|
|
217
|
+
} catch { /* index regen failure is non-fatal */ }
|
|
218
|
+
|
|
219
|
+
return decision;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Soft-delete a decision or proposal to history/.
|
|
224
|
+
* Returns { mode: 'decision'|'proposal'|'noop', archivedPath: string|null }.
|
|
225
|
+
*/
|
|
226
|
+
function forgetEntry(identity, slug) {
|
|
227
|
+
const decFilePath = decisionPath(identity, slug);
|
|
228
|
+
const propFilePath = proposalPath(identity, slug);
|
|
229
|
+
const now = new Date().toISOString();
|
|
230
|
+
|
|
231
|
+
if (fs.existsSync(decFilePath)) {
|
|
232
|
+
const archived = historyPath(identity, slug, now);
|
|
233
|
+
const content = fs.readFileSync(decFilePath, 'utf8');
|
|
234
|
+
fs.writeFileSync(archived, content, 'utf8');
|
|
235
|
+
|
|
236
|
+
const db = openIndexDb();
|
|
237
|
+
try {
|
|
238
|
+
db.transaction(() => {
|
|
239
|
+
db.prepare('DELETE FROM decisions_fts WHERE identity = ? AND slug = ?').run(identity, slug);
|
|
240
|
+
fs.unlinkSync(decFilePath);
|
|
241
|
+
})();
|
|
242
|
+
} finally {
|
|
243
|
+
db.close();
|
|
244
|
+
}
|
|
245
|
+
// Post-commit: regenerate MEMORY.md index after decision removed
|
|
246
|
+
try {
|
|
247
|
+
const { regenerateIndex } = require('./index-md');
|
|
248
|
+
regenerateIndex(identity);
|
|
249
|
+
} catch { /* index regen failure is non-fatal */ }
|
|
250
|
+
return { mode: 'decision', archivedPath: archived };
|
|
251
|
+
}
|
|
252
|
+
if (fs.existsSync(propFilePath)) {
|
|
253
|
+
const archived = historyPath(identity, slug, now);
|
|
254
|
+
fs.copyFileSync(propFilePath, archived);
|
|
255
|
+
deleteProposal(identity, slug);
|
|
256
|
+
return { mode: 'proposal', archivedPath: archived };
|
|
257
|
+
}
|
|
258
|
+
return { mode: 'noop', archivedPath: null };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
promoteProposal,
|
|
263
|
+
forgetEntry,
|
|
264
|
+
readDecision,
|
|
265
|
+
decisionPath,
|
|
266
|
+
historyPath,
|
|
267
|
+
serializeDecision,
|
|
268
|
+
parseDecisionFile,
|
|
269
|
+
inferCategory,
|
|
270
|
+
CATEGORY_KEYWORDS,
|
|
271
|
+
MAX_BODY_CHARS,
|
|
272
|
+
SCHEMA_VERSION,
|
|
273
|
+
VALID_CATEGORIES
|
|
274
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — identity resolution (Phase 1, v1.12.0).
|
|
5
|
+
*
|
|
6
|
+
* Pure helpers, no I/O side effects. Storage tree creation lives in storage.js.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order (PMD-05 + architecture-operator-memory.md § Phase 1):
|
|
9
|
+
* 1. process.env.AIOSON_OPERATOR_ID — if set + valid → use literal.
|
|
10
|
+
* 2. git config --get user.email — sha256[0..16]; salt-rehash if reserved-prefix collision.
|
|
11
|
+
* 3. fallback to reserved identity `_anonymous` with telemetry warning.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
const { execFileSync } = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
const HASH_PREFIX_BYTES = 16;
|
|
18
|
+
const OVERRIDE_REGEX = /^[a-z0-9][a-z0-9-]{2,31}$/;
|
|
19
|
+
const SALT_V1 = 'aioson-v1';
|
|
20
|
+
const RESERVED_PREFIXES = ['_', 'aioson-'];
|
|
21
|
+
|
|
22
|
+
function isReservedPrefix(value) {
|
|
23
|
+
return RESERVED_PREFIXES.some((prefix) => value.startsWith(prefix));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateOverride(value) {
|
|
27
|
+
if (typeof value !== 'string' || value === '') {
|
|
28
|
+
return { ok: false, reason: 'empty' };
|
|
29
|
+
}
|
|
30
|
+
// Check reserved-prefix BEFORE regex so the rejection reason matches the user's
|
|
31
|
+
// mental model: `_admin` is "reserved-prefix" (even though regex would also reject
|
|
32
|
+
// a leading `_`); `aioson-system` is "reserved-prefix" (regex passes but PMD-05 bans it).
|
|
33
|
+
if (isReservedPrefix(value)) {
|
|
34
|
+
return { ok: false, reason: 'reserved-prefix' };
|
|
35
|
+
}
|
|
36
|
+
if (!OVERRIDE_REGEX.test(value)) {
|
|
37
|
+
return { ok: false, reason: 'regex' };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hashEmail(email) {
|
|
43
|
+
const normalized = String(email || '').trim();
|
|
44
|
+
if (normalized === '') return null;
|
|
45
|
+
const raw = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, HASH_PREFIX_BYTES);
|
|
46
|
+
if (isReservedPrefix(raw)) {
|
|
47
|
+
return crypto.createHash('sha256').update(`${SALT_V1}:${normalized}`).digest('hex').slice(0, HASH_PREFIX_BYTES);
|
|
48
|
+
}
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readGitEmail() {
|
|
53
|
+
try {
|
|
54
|
+
const out = execFileSync('git', ['config', '--get', 'user.email'], {
|
|
55
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 2000
|
|
58
|
+
});
|
|
59
|
+
return out.trim();
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveIdentity({ env = process.env, emailReader = readGitEmail } = {}) {
|
|
66
|
+
const override = env.AIOSON_OPERATOR_ID;
|
|
67
|
+
if (override !== undefined && override !== null && override !== '') {
|
|
68
|
+
const validation = validateOverride(override);
|
|
69
|
+
if (validation.ok) {
|
|
70
|
+
return {
|
|
71
|
+
identity: override,
|
|
72
|
+
source: 'override',
|
|
73
|
+
warning: null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const warning = `AIOSON_OPERATOR_ID '${override}' invalid (reason: ${validation.reason}; expected ${OVERRIDE_REGEX}, no reserved prefix). Falling back to git-email-hash.`;
|
|
77
|
+
const hashFallback = hashEmail(emailReader());
|
|
78
|
+
if (hashFallback) {
|
|
79
|
+
return { identity: hashFallback, source: 'email-hash', warning };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
identity: '_anonymous',
|
|
83
|
+
source: 'anonymous-fallback',
|
|
84
|
+
warning: `${warning} Git email unavailable; using '_anonymous' bucket.`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const email = emailReader();
|
|
89
|
+
const hash = hashEmail(email);
|
|
90
|
+
if (hash) {
|
|
91
|
+
return { identity: hash, source: 'email-hash', warning: null };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
identity: '_anonymous',
|
|
95
|
+
source: 'anonymous-fallback',
|
|
96
|
+
warning: 'git config user.email unavailable; using `_anonymous` bucket. Set git config user.email or AIOSON_OPERATOR_ID to scope memory.'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
resolveIdentity,
|
|
102
|
+
validateOverride,
|
|
103
|
+
hashEmail,
|
|
104
|
+
readGitEmail,
|
|
105
|
+
HASH_PREFIX_BYTES,
|
|
106
|
+
OVERRIDE_REGEX,
|
|
107
|
+
SALT_V1,
|
|
108
|
+
RESERVED_PREFIXES
|
|
109
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — MEMORY.md tier-based index reader/writer (Phase 3, v1.14.0).
|
|
5
|
+
*
|
|
6
|
+
* Per PMD-AN-02: MEMORY.md = active tier (auto-loaded by preflight directive);
|
|
7
|
+
* MEMORY-archive.md = archive tier (lazy-loaded only with --include-archived).
|
|
8
|
+
*
|
|
9
|
+
* Format (architecture-operator-memory.md § Phase 3):
|
|
10
|
+
* ---
|
|
11
|
+
* identity_prefix: <first 8 chars of identity>
|
|
12
|
+
* decisions_count: N
|
|
13
|
+
* archived_count: M
|
|
14
|
+
* last_promoted: <ISO>
|
|
15
|
+
* schema_version: "1.0"
|
|
16
|
+
* ---
|
|
17
|
+
*
|
|
18
|
+
* # Operator Memory — Index
|
|
19
|
+
*
|
|
20
|
+
* ## Active decisions
|
|
21
|
+
*
|
|
22
|
+
* - [Title](decisions/{slug}.md) — {signal_type}, reinforced {date}
|
|
23
|
+
* ...
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { getStorageRoot } = require('./storage');
|
|
29
|
+
const { readDecision, decisionPath } = require('./decision');
|
|
30
|
+
|
|
31
|
+
const SCHEMA_VERSION = '1.0';
|
|
32
|
+
|
|
33
|
+
function indexPath(identity, tier = 'active') {
|
|
34
|
+
const filename = tier === 'archive' ? 'MEMORY-archive.md' : 'MEMORY.md';
|
|
35
|
+
return path.join(getStorageRoot(identity), filename);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listDecisionSlugs(identity) {
|
|
39
|
+
const root = path.join(getStorageRoot(identity), 'decisions');
|
|
40
|
+
if (!fs.existsSync(root)) return [];
|
|
41
|
+
return fs.readdirSync(root)
|
|
42
|
+
.filter((f) => f.endsWith('.md'))
|
|
43
|
+
.map((f) => f.slice(0, -3));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveLineForDecision(decision) {
|
|
47
|
+
const title = decision.body
|
|
48
|
+
? decision.body.slice(0, 80).split('\n')[0].replace(/[#`*\[\]()]/g, '').trim()
|
|
49
|
+
: decision.slug;
|
|
50
|
+
const dateOnly = String(decision.last_reinforced || '').slice(0, 10);
|
|
51
|
+
return `- [${title || decision.slug}](decisions/${decision.slug}.md) — ${decision.signal_type}, reinforced ${dateOnly}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializeIndex({ identity, decisionsCount, archivedCount, lastPromoted, lines, tier = 'active' }) {
|
|
55
|
+
const heading = tier === 'archive' ? 'Archived decisions' : 'Active decisions';
|
|
56
|
+
const frontmatter = [
|
|
57
|
+
'---',
|
|
58
|
+
`identity_prefix: ${identity.slice(0, 8)}`,
|
|
59
|
+
`decisions_count: ${decisionsCount}`,
|
|
60
|
+
`archived_count: ${archivedCount}`,
|
|
61
|
+
`last_promoted: ${lastPromoted || 'null'}`,
|
|
62
|
+
`schema_version: "${SCHEMA_VERSION}"`,
|
|
63
|
+
'---',
|
|
64
|
+
''
|
|
65
|
+
].join('\n');
|
|
66
|
+
const body = [
|
|
67
|
+
'# Operator Memory — Index',
|
|
68
|
+
'',
|
|
69
|
+
`## ${heading}`,
|
|
70
|
+
'',
|
|
71
|
+
lines.length === 0 ? '_(empty — no decisions yet)_' : lines.join('\n'),
|
|
72
|
+
''
|
|
73
|
+
];
|
|
74
|
+
if (tier === 'active') {
|
|
75
|
+
body.push('## See also', '');
|
|
76
|
+
body.push(`- \`MEMORY-archive.md\` — ${archivedCount} archived decisions`);
|
|
77
|
+
body.push('');
|
|
78
|
+
}
|
|
79
|
+
return frontmatter + body.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseIndexFrontmatter(content) {
|
|
83
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const line of match[1].split('\n')) {
|
|
87
|
+
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
88
|
+
if (m) {
|
|
89
|
+
let v = m[2].trim();
|
|
90
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
91
|
+
if (/^\d+$/.test(v)) v = Number(v);
|
|
92
|
+
if (v === 'null') v = null;
|
|
93
|
+
out[m[1]] = v;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseIndexLinks(content) {
|
|
100
|
+
// Match `- [Title](decisions/{slug}.md) — {signal_type}, reinforced {date}`
|
|
101
|
+
const re = /^- \[(.*?)\]\(decisions\/([a-z0-9-]+)\.md\) — (\w+), reinforced (\S+)/gm;
|
|
102
|
+
const out = [];
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(content)) !== null) {
|
|
105
|
+
out.push({ title: m[1], slug: m[2], signal_type: m[3], last_reinforced: m[4] });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadMemoryIndex(identity, tier = 'active') {
|
|
111
|
+
const p = indexPath(identity, tier);
|
|
112
|
+
if (!fs.existsSync(p)) return null;
|
|
113
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
114
|
+
return {
|
|
115
|
+
frontmatter: parseIndexFrontmatter(content),
|
|
116
|
+
entries: parseIndexLinks(content),
|
|
117
|
+
raw: content
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Regenerate MEMORY.md from current decisions on disk.
|
|
123
|
+
*
|
|
124
|
+
* Phase 3 V1: includes ALL decisions in active tier (no decay yet).
|
|
125
|
+
* Phase 5 will partition into active vs archive based on category half-life.
|
|
126
|
+
*/
|
|
127
|
+
function regenerateIndex(identity) {
|
|
128
|
+
const slugs = listDecisionSlugs(identity);
|
|
129
|
+
const decisions = slugs
|
|
130
|
+
.map((slug) => {
|
|
131
|
+
try {
|
|
132
|
+
const d = readDecision(identity, slug);
|
|
133
|
+
if (!d) return null;
|
|
134
|
+
return { slug, ...d };
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.sort((a, b) => String(b.last_reinforced || '').localeCompare(String(a.last_reinforced || '')));
|
|
141
|
+
|
|
142
|
+
const lines = decisions.map(deriveLineForDecision);
|
|
143
|
+
const lastPromoted = decisions[0]?.promoted_at || null;
|
|
144
|
+
const indexContent = serializeIndex({
|
|
145
|
+
identity,
|
|
146
|
+
decisionsCount: decisions.length,
|
|
147
|
+
archivedCount: 0, // Phase 5 will compute from MEMORY-archive.md
|
|
148
|
+
lastPromoted,
|
|
149
|
+
lines,
|
|
150
|
+
tier: 'active'
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const p = indexPath(identity, 'active');
|
|
154
|
+
const tmp = `${p}.tmp`;
|
|
155
|
+
fs.writeFileSync(tmp, indexContent, 'utf8');
|
|
156
|
+
fs.renameSync(tmp, p);
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
loadMemoryIndex,
|
|
162
|
+
regenerateIndex,
|
|
163
|
+
indexPath,
|
|
164
|
+
parseIndexFrontmatter,
|
|
165
|
+
parseIndexLinks,
|
|
166
|
+
serializeIndex,
|
|
167
|
+
deriveLineForDecision,
|
|
168
|
+
listDecisionSlugs,
|
|
169
|
+
SCHEMA_VERSION
|
|
170
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — preflight loader + lazy decision matcher (Phase 3, v1.14.0).
|
|
5
|
+
*
|
|
6
|
+
* Architecture-operator-memory.md § Phase 3 data flow:
|
|
7
|
+
* AIOSON_OPERATOR_MEMORY=true → resolveIdentity → loadMemoryIndex(active)
|
|
8
|
+
* → matchDecisions(index, task keywords) → return top-N matched slugs
|
|
9
|
+
* → agent lazy-loads decisions/{slug}.md
|
|
10
|
+
*
|
|
11
|
+
* V1 match heuristic: substring on title + signal_type tag (AC-P3-10).
|
|
12
|
+
* V2: FTS5-backed query optimization (deferred).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { loadMemoryIndex } = require('./index-md');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_MATCHES = 5;
|
|
18
|
+
const STOPWORDS = new Set([
|
|
19
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'with', 'is', 'be',
|
|
20
|
+
'que', 'de', 'em', 'para', 'sem', 'com', 'um', 'uma', 'os', 'as', 'no', 'na'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function tokenize(text) {
|
|
24
|
+
return String(text || '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.normalize('NFD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '')
|
|
28
|
+
.split(/[^a-z0-9]+/)
|
|
29
|
+
.filter((w) => w && !STOPWORDS.has(w) && w.length >= 3);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Match decisions by keyword overlap with task description.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} memoryIndex — output of loadMemoryIndex(identity, 'active')
|
|
36
|
+
* @param {string} taskDescription — current task description / user goal
|
|
37
|
+
* @param {object} options — { maxMatches: number, minOverlap: number }
|
|
38
|
+
* @returns {Array<{slug: string, title: string, score: number}>}
|
|
39
|
+
*/
|
|
40
|
+
function matchDecisions(memoryIndex, taskDescription, options = {}) {
|
|
41
|
+
if (!memoryIndex || !memoryIndex.entries || memoryIndex.entries.length === 0) return [];
|
|
42
|
+
const max = options.maxMatches || DEFAULT_MAX_MATCHES;
|
|
43
|
+
const minOverlap = options.minOverlap || 1;
|
|
44
|
+
|
|
45
|
+
const taskTokens = new Set(tokenize(taskDescription));
|
|
46
|
+
if (taskTokens.size === 0) return [];
|
|
47
|
+
|
|
48
|
+
const scored = memoryIndex.entries.map((entry) => {
|
|
49
|
+
const entryTokens = new Set(tokenize(`${entry.title} ${entry.signal_type}`));
|
|
50
|
+
let overlap = 0;
|
|
51
|
+
for (const t of taskTokens) {
|
|
52
|
+
if (entryTokens.has(t)) overlap += 1;
|
|
53
|
+
}
|
|
54
|
+
return { slug: entry.slug, title: entry.title, signal_type: entry.signal_type, score: overlap };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return scored
|
|
58
|
+
.filter((s) => s.score >= minOverlap)
|
|
59
|
+
.sort((a, b) => b.score - a.score)
|
|
60
|
+
.slice(0, max);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convenience wrapper combining load + match + conflict detection for preflight use.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} identity — operator identity hash or override
|
|
67
|
+
* @param {string} taskDescription — current task / user goal
|
|
68
|
+
* @param {object} options — { maxMatches, minOverlap, projectRoot, detectConflicts }
|
|
69
|
+
* @returns {{index, matches, conflicts}} — conflicts is [] when projectRoot absent
|
|
70
|
+
* OR when Phase 4 conflict detection is not requested.
|
|
71
|
+
*/
|
|
72
|
+
function preflightLoad(identity, taskDescription, options = {}) {
|
|
73
|
+
const index = loadMemoryIndex(identity, 'active');
|
|
74
|
+
if (!index) return { index: null, matches: [], conflicts: [] };
|
|
75
|
+
const matches = matchDecisions(index, taskDescription, options);
|
|
76
|
+
|
|
77
|
+
let conflicts = [];
|
|
78
|
+
if (options.projectRoot && options.detectConflicts !== false) {
|
|
79
|
+
try {
|
|
80
|
+
const { scanProjectRules, detectConflicts, debounceConflicts } = require('./conflict');
|
|
81
|
+
const { readDecision } = require('./decision');
|
|
82
|
+
// Resolve full decision data for matched entries so conflict-body checks have content
|
|
83
|
+
const matchedDecisions = matches
|
|
84
|
+
.map((m) => {
|
|
85
|
+
try {
|
|
86
|
+
const d = readDecision(identity, m.slug);
|
|
87
|
+
return d ? { slug: m.slug, ...d } : null;
|
|
88
|
+
} catch { return null; }
|
|
89
|
+
})
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
const rules = scanProjectRules(options.projectRoot);
|
|
92
|
+
const detected = detectConflicts(matchedDecisions, rules, options);
|
|
93
|
+
conflicts = options.skipDebounce ? detected : debounceConflicts(identity, detected, options);
|
|
94
|
+
} catch { /* conflict detection failure must not crash preflight */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { index, matches, conflicts };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
preflightLoad,
|
|
102
|
+
matchDecisions,
|
|
103
|
+
tokenize,
|
|
104
|
+
DEFAULT_MAX_MATCHES,
|
|
105
|
+
STOPWORDS
|
|
106
|
+
};
|