@jaimevalasek/aioson 1.9.3 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +44 -1
  3. package/package.json +1 -1
  4. package/src/cli.js +45 -1
  5. package/src/commands/op-capture.js +146 -0
  6. package/src/commands/op-forget.js +54 -0
  7. package/src/commands/op-identity.js +145 -0
  8. package/src/commands/op-list.js +105 -0
  9. package/src/commands/op-migrate.js +158 -0
  10. package/src/commands/op-promote.js +66 -0
  11. package/src/commands/op-reinforce.js +73 -0
  12. package/src/commands/op-show.js +71 -0
  13. package/src/commands/op-stubs.js +67 -0
  14. package/src/commands/preflight.js +6 -2
  15. package/src/commands/runtime.js +151 -0
  16. package/src/commands/state-save.js +61 -0
  17. package/src/commands/sync-agents-preflight.js +117 -3
  18. package/src/commands/workflow-next.js +64 -0
  19. package/src/handoff-contract.js +25 -0
  20. package/src/lib/agent-semantic-diff.js +199 -0
  21. package/src/operator-memory/conflict.js +202 -0
  22. package/src/operator-memory/decay.js +157 -0
  23. package/src/operator-memory/decision.js +274 -0
  24. package/src/operator-memory/identity.js +109 -0
  25. package/src/operator-memory/index-md.js +170 -0
  26. package/src/operator-memory/loader.js +106 -0
  27. package/src/operator-memory/proposal.js +179 -0
  28. package/src/operator-memory/prune.js +81 -0
  29. package/src/operator-memory/slug.js +90 -0
  30. package/src/operator-memory/storage.js +121 -0
  31. package/src/preflight-engine.js +91 -1
  32. package/template/.aioson/agents/dev.md +1 -1
  33. package/template/.aioson/agents/deyvin.md +3 -3
  34. package/template/.aioson/agents/neo.md +1 -1
  35. package/template/.aioson/agents/product.md +1 -1
  36. package/template/.aioson/agents/setup.md +1 -1
  37. package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
  38. package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
  39. package/template/AGENTS.md +23 -0
  40. package/template/CLAUDE.md +23 -0
  41. 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
+ };