@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,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agent-semantic-diff — pure helpers for detecting semantic drift between
|
|
5
|
+
* workspace `.aioson/agents/{agent}.md` and template `template/.aioson/agents/{agent}.md`.
|
|
6
|
+
*
|
|
7
|
+
* F4 / T5 (workflow-handoff-integrity v1.9.8). Designed to catch the 981a8fd-style
|
|
8
|
+
* migration where the workspace agent prompt was updated but the template was not.
|
|
9
|
+
* The existing `sync-agents-preflight#checkParity` only inspects the `## Feature dossier`
|
|
10
|
+
* section length — this helper extends that lens to headers, section content, and
|
|
11
|
+
* frontmatter.
|
|
12
|
+
*
|
|
13
|
+
* Three diff strategies (per DD-03):
|
|
14
|
+
* - diffHeaders — section list (presence + order) at `##` and `###` levels
|
|
15
|
+
* - diffSectionContent — hash-based diff of section bodies (catches content drift)
|
|
16
|
+
* - diffFrontmatter — field-level YAML-ish frontmatter comparison
|
|
17
|
+
*
|
|
18
|
+
* Plain text body diff is deliberately skipped — too noisy for typos/cosmetic edits.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
|
|
23
|
+
// ─── Frontmatter ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function extractFrontmatter(content) {
|
|
26
|
+
const match = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
30
|
+
const idx = line.indexOf(':');
|
|
31
|
+
if (idx === -1) continue;
|
|
32
|
+
const key = line.slice(0, idx).trim();
|
|
33
|
+
if (!key) continue;
|
|
34
|
+
out[key] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function diffFrontmatter(workspaceContent, templateContent) {
|
|
40
|
+
const ws = extractFrontmatter(workspaceContent);
|
|
41
|
+
const tpl = extractFrontmatter(templateContent);
|
|
42
|
+
if (ws === null && tpl === null) return null; // both have no frontmatter
|
|
43
|
+
const missingInTemplate = [];
|
|
44
|
+
const missingInWorkspace = [];
|
|
45
|
+
const valueChanged = [];
|
|
46
|
+
const wsObj = ws || {};
|
|
47
|
+
const tplObj = tpl || {};
|
|
48
|
+
for (const key of Object.keys(wsObj)) {
|
|
49
|
+
if (!(key in tplObj)) {
|
|
50
|
+
missingInTemplate.push(key);
|
|
51
|
+
} else if (wsObj[key] !== tplObj[key]) {
|
|
52
|
+
valueChanged.push({ key, workspace: wsObj[key], template: tplObj[key] });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const key of Object.keys(tplObj)) {
|
|
56
|
+
if (!(key in wsObj)) missingInWorkspace.push(key);
|
|
57
|
+
}
|
|
58
|
+
if (missingInTemplate.length + missingInWorkspace.length + valueChanged.length === 0) return null;
|
|
59
|
+
return { missingInTemplate, missingInWorkspace, valueChanged };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Headers ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract ## and ### headers in document order.
|
|
66
|
+
* Skips anything inside fenced code blocks.
|
|
67
|
+
*/
|
|
68
|
+
function extractHeaders(content) {
|
|
69
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
70
|
+
const headers = [];
|
|
71
|
+
let inFence = false;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (/^```/.test(line.trim())) {
|
|
74
|
+
inFence = !inFence;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (inFence) continue;
|
|
78
|
+
const m = line.match(/^(##{1,2})\s+(.+?)\s*$/);
|
|
79
|
+
if (m) headers.push(m[2].trim());
|
|
80
|
+
}
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function diffHeaders(workspaceContent, templateContent) {
|
|
85
|
+
const ws = extractHeaders(workspaceContent);
|
|
86
|
+
const tpl = extractHeaders(templateContent);
|
|
87
|
+
const wsSet = new Set(ws);
|
|
88
|
+
const tplSet = new Set(tpl);
|
|
89
|
+
const missingInTemplate = ws.filter((h) => !tplSet.has(h));
|
|
90
|
+
const missingInWorkspace = tpl.filter((h) => !wsSet.has(h));
|
|
91
|
+
// Order check: of the headers present in both, do they appear in the same sequence?
|
|
92
|
+
const common = ws.filter((h) => tplSet.has(h));
|
|
93
|
+
const commonInTpl = tpl.filter((h) => wsSet.has(h));
|
|
94
|
+
const reordered = common.length === commonInTpl.length
|
|
95
|
+
&& common.some((h, i) => h !== commonInTpl[i]);
|
|
96
|
+
if (missingInTemplate.length + missingInWorkspace.length === 0 && !reordered) return null;
|
|
97
|
+
return { missingInTemplate, missingInWorkspace, reordered };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Section content (hash-based) ────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split content into Map<header, body>. Body is normalized (trimmed lines,
|
|
104
|
+
* collapsed whitespace) before hashing to avoid cosmetic-only false positives.
|
|
105
|
+
*/
|
|
106
|
+
function extractSections(content) {
|
|
107
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
108
|
+
const sections = new Map();
|
|
109
|
+
let current = '__preamble__';
|
|
110
|
+
let body = [];
|
|
111
|
+
let inFence = false;
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (/^```/.test(line.trim())) {
|
|
114
|
+
inFence = !inFence;
|
|
115
|
+
body.push(line);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const headerMatch = !inFence && line.match(/^(##{1,2})\s+(.+?)\s*$/);
|
|
119
|
+
if (headerMatch) {
|
|
120
|
+
sections.set(current, body.join('\n'));
|
|
121
|
+
current = headerMatch[2].trim();
|
|
122
|
+
body = [];
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
body.push(line);
|
|
126
|
+
}
|
|
127
|
+
sections.set(current, body.join('\n'));
|
|
128
|
+
return sections;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeBody(body) {
|
|
132
|
+
return String(body || '')
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hashBody(body) {
|
|
140
|
+
return crypto.createHash('sha256').update(normalizeBody(body)).digest('hex').slice(0, 16);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function diffSectionContent(workspaceContent, templateContent) {
|
|
144
|
+
const ws = extractSections(workspaceContent);
|
|
145
|
+
const tpl = extractSections(templateContent);
|
|
146
|
+
const diverged = [];
|
|
147
|
+
for (const [header, wsBody] of ws.entries()) {
|
|
148
|
+
if (!tpl.has(header)) continue; // missing-header case handled by diffHeaders
|
|
149
|
+
const wsHash = hashBody(wsBody);
|
|
150
|
+
const tplHash = hashBody(tpl.get(header));
|
|
151
|
+
if (wsHash !== tplHash) {
|
|
152
|
+
diverged.push({ header, workspaceHash: wsHash, templateHash: tplHash });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return diverged.length > 0 ? diverged : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Aggregate runner ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run all three diff strategies on a single agent file pair.
|
|
162
|
+
* Returns null when no drift is detected, otherwise an issue object.
|
|
163
|
+
*/
|
|
164
|
+
function diffAgentFile(workspaceContent, templateContent) {
|
|
165
|
+
// AC-T5-08: missing-on-one-side detection.
|
|
166
|
+
if (!workspaceContent && !templateContent) return null;
|
|
167
|
+
if (!workspaceContent || !templateContent) {
|
|
168
|
+
return {
|
|
169
|
+
missingFile: !workspaceContent ? 'workspace' : 'template',
|
|
170
|
+
missingInTemplate: [], missingInWorkspace: [], reordered: false,
|
|
171
|
+
divergedSections: [],
|
|
172
|
+
frontmatter: null
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const headers = diffHeaders(workspaceContent, templateContent);
|
|
176
|
+
const sections = diffSectionContent(workspaceContent, templateContent);
|
|
177
|
+
const frontmatter = diffFrontmatter(workspaceContent, templateContent);
|
|
178
|
+
if (!headers && !sections && !frontmatter) return null;
|
|
179
|
+
return {
|
|
180
|
+
missingFile: null,
|
|
181
|
+
missingInTemplate: headers?.missingInTemplate || [],
|
|
182
|
+
missingInWorkspace: headers?.missingInWorkspace || [],
|
|
183
|
+
reordered: headers?.reordered || false,
|
|
184
|
+
divergedSections: sections || [],
|
|
185
|
+
frontmatter: frontmatter || null
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
extractFrontmatter,
|
|
191
|
+
extractHeaders,
|
|
192
|
+
extractSections,
|
|
193
|
+
diffFrontmatter,
|
|
194
|
+
diffHeaders,
|
|
195
|
+
diffSectionContent,
|
|
196
|
+
diffAgentFile,
|
|
197
|
+
hashBody,
|
|
198
|
+
normalizeBody
|
|
199
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — conflict policy + warning surface (Phase 4, v1.15.0).
|
|
5
|
+
*
|
|
6
|
+
* V1 binary policy (PMD-09): project rules in `.aioson/rules/` always win.
|
|
7
|
+
* Operator decisions are unchanged on conflict — only a stderr warning is
|
|
8
|
+
* emitted, debounced per (decision_slug, rule_path) pair via _conflict_state.json
|
|
9
|
+
* with a 60s window (AC-P4-03).
|
|
10
|
+
*
|
|
11
|
+
* Detection (V1 keyword-based — V2 will move to LLM-tagged):
|
|
12
|
+
* 1. Rule frontmatter must opt in: `conflicts_with_signal_types: [authorization, ...]`
|
|
13
|
+
* Rules without this field generate zero false positives (additive policy).
|
|
14
|
+
* 2. Operator decision's signal_type must intersect rule's `conflicts_with_signal_types`.
|
|
15
|
+
* 3. Keyword overlap ≥ 2 between rule body and decision body (case-insensitive,
|
|
16
|
+
* stopwords filtered). Configurable via env `AIOSON_OPERATOR_CONFLICT_KEYWORD_THRESHOLD`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { getStorageRoot } = require('./storage');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_KEYWORD_THRESHOLD = 2;
|
|
24
|
+
const DEFAULT_DEBOUNCE_MS = 60_000;
|
|
25
|
+
const CONFLICT_STATE_FILE = '_conflict_state.json';
|
|
26
|
+
|
|
27
|
+
const STOPWORDS = new Set([
|
|
28
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'with', 'is', 'be',
|
|
29
|
+
'que', 'de', 'em', 'para', 'sem', 'com', 'um', 'uma', 'os', 'as', 'no', 'na'
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function tokenize(text) {
|
|
33
|
+
return String(text || '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.normalize('NFD')
|
|
36
|
+
.replace(/[̀-ͯ]/g, '')
|
|
37
|
+
.split(/[^a-z0-9]+/)
|
|
38
|
+
.filter((w) => w && !STOPWORDS.has(w) && w.length >= 3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keywordOverlap(textA, textB) {
|
|
42
|
+
const a = new Set(tokenize(textA));
|
|
43
|
+
const b = new Set(tokenize(textB));
|
|
44
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
45
|
+
let overlap = 0;
|
|
46
|
+
for (const t of a) if (b.has(t)) overlap += 1;
|
|
47
|
+
return overlap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRuleFrontmatter(content) {
|
|
51
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
const out = {};
|
|
54
|
+
let inList = null;
|
|
55
|
+
for (const rawLine of m[1].split('\n')) {
|
|
56
|
+
if (inList) {
|
|
57
|
+
const listItem = rawLine.match(/^\s+-\s+(.*)$/);
|
|
58
|
+
if (listItem) {
|
|
59
|
+
out[inList].push(listItem[1].trim().replace(/^['"]|['"]$/g, ''));
|
|
60
|
+
continue;
|
|
61
|
+
} else if (rawLine.trim() === '' || /^[a-z_]+:/.test(rawLine)) {
|
|
62
|
+
inList = null;
|
|
63
|
+
// fall through to parse this line as regular field
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const arrayLineMatch = rawLine.match(/^([a-z_]+):\s*\[(.*)\]$/);
|
|
67
|
+
if (arrayLineMatch) {
|
|
68
|
+
out[arrayLineMatch[1]] = arrayLineMatch[2].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const listStartMatch = rawLine.match(/^([a-z_]+):\s*$/);
|
|
72
|
+
if (listStartMatch) {
|
|
73
|
+
out[listStartMatch[1]] = [];
|
|
74
|
+
inList = listStartMatch[1];
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const fieldMatch = rawLine.match(/^([a-z_]+):\s*(.+)$/);
|
|
78
|
+
if (fieldMatch) {
|
|
79
|
+
let v = fieldMatch[2].trim();
|
|
80
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
81
|
+
out[fieldMatch[1]] = v;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readRule(rulePath) {
|
|
88
|
+
const content = fs.readFileSync(rulePath, 'utf8');
|
|
89
|
+
const fm = parseRuleFrontmatter(content);
|
|
90
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
91
|
+
return { frontmatter: fm || {}, body, path: rulePath };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scanProjectRules(projectRoot) {
|
|
95
|
+
const rulesDir = path.join(projectRoot, '.aioson', 'rules');
|
|
96
|
+
if (!fs.existsSync(rulesDir)) return [];
|
|
97
|
+
const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md') && f !== 'README.md');
|
|
98
|
+
return files.map((f) => readRule(path.join(rulesDir, f)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect conflicts between loaded operator decisions and project rules.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<object>} decisions — decisions to check (e.g. matched by preflightLoad)
|
|
105
|
+
* @param {Array<object>} rules — output of scanProjectRules(projectRoot)
|
|
106
|
+
* @param {object} options — { threshold }
|
|
107
|
+
* @returns {Array<{decision_slug, rule_path, rule_basename, reason, severity, overlap}>}
|
|
108
|
+
*/
|
|
109
|
+
function detectConflicts(decisions, rules, options = {}) {
|
|
110
|
+
const threshold = options.threshold || DEFAULT_KEYWORD_THRESHOLD;
|
|
111
|
+
const conflicts = [];
|
|
112
|
+
|
|
113
|
+
for (const decision of decisions) {
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
const conflictSignals = rule.frontmatter.conflicts_with_signal_types;
|
|
116
|
+
if (!Array.isArray(conflictSignals) || conflictSignals.length === 0) continue;
|
|
117
|
+
if (!conflictSignals.includes(decision.signal_type)) continue;
|
|
118
|
+
const overlap = keywordOverlap(rule.body, decision.body || decision.proposal || '');
|
|
119
|
+
if (overlap >= threshold) {
|
|
120
|
+
conflicts.push({
|
|
121
|
+
decision_slug: decision.slug,
|
|
122
|
+
rule_path: rule.path,
|
|
123
|
+
rule_basename: path.basename(rule.path),
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
overlap,
|
|
126
|
+
reason: `keyword overlap=${overlap} ≥ threshold=${threshold}, signal_type=${decision.signal_type} in rule.conflicts_with_signal_types`
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return conflicts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatConflictWarning(conflict) {
|
|
136
|
+
return `⚠ Operator memory '${conflict.decision_slug}' conflicts with project rule '${conflict.rule_basename}'. Project rule applies.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function conflictStatePath(identity) {
|
|
140
|
+
return path.join(getStorageRoot(identity), CONFLICT_STATE_FILE);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function loadConflictState(identity) {
|
|
144
|
+
const p = conflictStatePath(identity);
|
|
145
|
+
if (!fs.existsSync(p)) return {};
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
148
|
+
} catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function saveConflictState(identity, state) {
|
|
154
|
+
const p = conflictStatePath(identity);
|
|
155
|
+
const tmp = `${p}.tmp`;
|
|
156
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
157
|
+
fs.renameSync(tmp, p);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Filter conflicts to those whose debounce window has elapsed.
|
|
162
|
+
* Updates state file with new last_warned_at timestamps for emitted ones.
|
|
163
|
+
*
|
|
164
|
+
* @returns {Array} subset of conflicts that should be emitted right now
|
|
165
|
+
*/
|
|
166
|
+
function debounceConflicts(identity, conflicts, options = {}) {
|
|
167
|
+
if (conflicts.length === 0) return [];
|
|
168
|
+
const debounceMs = options.debounceMs || DEFAULT_DEBOUNCE_MS;
|
|
169
|
+
const state = loadConflictState(identity);
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const toEmit = [];
|
|
172
|
+
let stateChanged = false;
|
|
173
|
+
for (const c of conflicts) {
|
|
174
|
+
const key = `${c.decision_slug}::${c.rule_basename}`;
|
|
175
|
+
const last = state[key];
|
|
176
|
+
if (!last || (now - new Date(last).getTime()) >= debounceMs) {
|
|
177
|
+
toEmit.push(c);
|
|
178
|
+
state[key] = new Date(now).toISOString();
|
|
179
|
+
stateChanged = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (stateChanged) {
|
|
183
|
+
try { saveConflictState(identity, state); } catch { /* non-fatal */ }
|
|
184
|
+
}
|
|
185
|
+
return toEmit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
detectConflicts,
|
|
190
|
+
debounceConflicts,
|
|
191
|
+
formatConflictWarning,
|
|
192
|
+
scanProjectRules,
|
|
193
|
+
readRule,
|
|
194
|
+
parseRuleFrontmatter,
|
|
195
|
+
keywordOverlap,
|
|
196
|
+
tokenize,
|
|
197
|
+
loadConflictState,
|
|
198
|
+
saveConflictState,
|
|
199
|
+
DEFAULT_KEYWORD_THRESHOLD,
|
|
200
|
+
DEFAULT_DEBOUNCE_MS,
|
|
201
|
+
CONFLICT_STATE_FILE
|
|
202
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — per-category TTL decay engine (Phase 5, v1.16.0).
|
|
5
|
+
*
|
|
6
|
+
* PMD-03: category half-life (identity=365d, autonomy=180d, tooling=90d, default=90d).
|
|
7
|
+
* Decay prompt fires when (now - last_reinforced) >= half_life, debounced 30d per slug
|
|
8
|
+
* via _decay_state.json.
|
|
9
|
+
*
|
|
10
|
+
* Per-category override via env: AIOSON_OPERATOR_DECAY_<CATEGORY>_DAYS (e.g.
|
|
11
|
+
* AIOSON_OPERATOR_DECAY_IDENTITY_DAYS=730 for 2-year identity persistence).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { getStorageRoot } = require('./storage');
|
|
17
|
+
const { readDecision } = require('./decision');
|
|
18
|
+
const { listDecisionSlugs } = require('./index-md');
|
|
19
|
+
|
|
20
|
+
const DECAY_STATE_FILE = '_decay_state.json';
|
|
21
|
+
|
|
22
|
+
const HALF_LIFE_DAYS_DEFAULT = {
|
|
23
|
+
identity: 365,
|
|
24
|
+
autonomy: 180,
|
|
25
|
+
tooling: 90,
|
|
26
|
+
default: 90
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const DEFAULT_PROMPT_DEBOUNCE_DAYS = 30;
|
|
31
|
+
|
|
32
|
+
function halfLifeForCategory(category) {
|
|
33
|
+
const envKey = `AIOSON_OPERATOR_DECAY_${String(category).toUpperCase()}_DAYS`;
|
|
34
|
+
const override = process.env[envKey];
|
|
35
|
+
if (override && !Number.isNaN(Number(override))) {
|
|
36
|
+
return Number(override);
|
|
37
|
+
}
|
|
38
|
+
return HALF_LIFE_DAYS_DEFAULT[category] ?? HALF_LIFE_DAYS_DEFAULT.default;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decayStatePath(identity) {
|
|
42
|
+
return path.join(getStorageRoot(identity), DECAY_STATE_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadDecayState(identity) {
|
|
46
|
+
const p = decayStatePath(identity);
|
|
47
|
+
if (!fs.existsSync(p)) return {};
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveDecayState(identity, state) {
|
|
56
|
+
const p = decayStatePath(identity);
|
|
57
|
+
const tmp = `${p}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
59
|
+
fs.renameSync(tmp, p);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute days since last_reinforced for a decision.
|
|
64
|
+
*/
|
|
65
|
+
function daysSinceReinforced(decision, now = Date.now()) {
|
|
66
|
+
const reinforced = new Date(decision.last_reinforced || decision.promoted_at || 0).getTime();
|
|
67
|
+
if (!reinforced) return Infinity;
|
|
68
|
+
return Math.floor((now - reinforced) / DAY_MS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Identify stale decisions whose decay-prompt debounce window has elapsed.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Array<{slug, category, days_stale, half_life}>} candidates to surface
|
|
75
|
+
*/
|
|
76
|
+
function findStaleDecisions(identity, options = {}) {
|
|
77
|
+
const now = options.now || Date.now();
|
|
78
|
+
const debounceDays = options.debounceDays ?? DEFAULT_PROMPT_DEBOUNCE_DAYS;
|
|
79
|
+
const state = loadDecayState(identity);
|
|
80
|
+
const slugs = listDecisionSlugs(identity);
|
|
81
|
+
const stale = [];
|
|
82
|
+
|
|
83
|
+
for (const slug of slugs) {
|
|
84
|
+
const decision = readDecision(identity, slug);
|
|
85
|
+
if (!decision) continue;
|
|
86
|
+
const category = decision.category || 'default';
|
|
87
|
+
const halfLife = halfLifeForCategory(category);
|
|
88
|
+
const daysStale = daysSinceReinforced(decision, now);
|
|
89
|
+
if (daysStale < halfLife) continue;
|
|
90
|
+
|
|
91
|
+
const last = state[slug];
|
|
92
|
+
if (last) {
|
|
93
|
+
const daysSincePrompt = Math.floor((now - new Date(last).getTime()) / DAY_MS);
|
|
94
|
+
if (daysSincePrompt < debounceDays) continue;
|
|
95
|
+
}
|
|
96
|
+
stale.push({ slug, category, days_stale: daysStale, half_life: halfLife, title: decision.body?.slice(0, 80) || slug });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return stale;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mark a stale decision's prompt as shown (updates debounce timestamp).
|
|
104
|
+
*/
|
|
105
|
+
function markDecayPromptShown(identity, slug) {
|
|
106
|
+
const state = loadDecayState(identity);
|
|
107
|
+
state[slug] = new Date().toISOString();
|
|
108
|
+
saveDecayState(identity, state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatDecayPrompt(stale) {
|
|
112
|
+
return `⏱ Memory '${stale.slug}' is ${stale.days_stale}d stale (${stale.category}, half-life=${stale.half_life}d). Still valid? aioson op:reinforce ${stale.slug} | op:forget ${stale.slug}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clean up history/ entries older than maxAgeDays (default 365). Phase 5
|
|
117
|
+
* cleanup runs alongside decay sweep — hard-delete old soft-deleted items.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Array<string>} paths of files removed
|
|
120
|
+
*/
|
|
121
|
+
function cleanupHistory(identity, options = {}) {
|
|
122
|
+
const maxAgeDays = options.maxAgeDays || 365;
|
|
123
|
+
const now = options.now || Date.now();
|
|
124
|
+
const historyDir = path.join(getStorageRoot(identity), 'history');
|
|
125
|
+
if (!fs.existsSync(historyDir)) return [];
|
|
126
|
+
|
|
127
|
+
const removed = [];
|
|
128
|
+
for (const f of fs.readdirSync(historyDir)) {
|
|
129
|
+
if (!f.endsWith('.md')) continue;
|
|
130
|
+
const full = path.join(historyDir, f);
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(full);
|
|
133
|
+
const ageDays = Math.floor((now - stat.mtimeMs) / DAY_MS);
|
|
134
|
+
if (ageDays >= maxAgeDays) {
|
|
135
|
+
fs.unlinkSync(full);
|
|
136
|
+
removed.push(full);
|
|
137
|
+
}
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return removed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
findStaleDecisions,
|
|
146
|
+
markDecayPromptShown,
|
|
147
|
+
formatDecayPrompt,
|
|
148
|
+
cleanupHistory,
|
|
149
|
+
loadDecayState,
|
|
150
|
+
saveDecayState,
|
|
151
|
+
halfLifeForCategory,
|
|
152
|
+
daysSinceReinforced,
|
|
153
|
+
HALF_LIFE_DAYS_DEFAULT,
|
|
154
|
+
DEFAULT_PROMPT_DEBOUNCE_DAYS,
|
|
155
|
+
DECAY_STATE_FILE,
|
|
156
|
+
DAY_MS
|
|
157
|
+
};
|