@jaimevalasek/aioson 1.9.3 → 1.17.2

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 (54) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/README.md +44 -1
  3. package/package.json +1 -1
  4. package/src/cli.js +50 -1
  5. package/src/commands/chain-audit.js +156 -0
  6. package/src/commands/op-capture.js +146 -0
  7. package/src/commands/op-forget.js +54 -0
  8. package/src/commands/op-identity.js +145 -0
  9. package/src/commands/op-list.js +105 -0
  10. package/src/commands/op-migrate.js +158 -0
  11. package/src/commands/op-promote.js +66 -0
  12. package/src/commands/op-reinforce.js +73 -0
  13. package/src/commands/op-show.js +71 -0
  14. package/src/commands/op-stubs.js +67 -0
  15. package/src/commands/preflight.js +6 -2
  16. package/src/commands/runtime.js +178 -0
  17. package/src/commands/state-save.js +61 -0
  18. package/src/commands/sync-agents-preflight.js +117 -3
  19. package/src/commands/workflow-next.js +64 -0
  20. package/src/handoff-contract.js +25 -0
  21. package/src/i18n/messages/en.js +9 -0
  22. package/src/i18n/messages/es.js +9 -0
  23. package/src/i18n/messages/fr.js +9 -0
  24. package/src/i18n/messages/pt-BR.js +9 -0
  25. package/src/lib/agent-semantic-diff.js +199 -0
  26. package/src/neural-chain-agent-ingest.js +400 -0
  27. package/src/neural-chain-config.js +95 -0
  28. package/src/neural-chain-git-ingest.js +280 -0
  29. package/src/neural-chain-migration.js +61 -0
  30. package/src/neural-chain-noise-file.js +332 -0
  31. package/src/neural-chain-sanitize.js +0 -0
  32. package/src/neural-chain-telemetry.js +90 -0
  33. package/src/operator-memory/conflict.js +202 -0
  34. package/src/operator-memory/decay.js +157 -0
  35. package/src/operator-memory/decision.js +274 -0
  36. package/src/operator-memory/identity.js +109 -0
  37. package/src/operator-memory/index-md.js +170 -0
  38. package/src/operator-memory/loader.js +106 -0
  39. package/src/operator-memory/proposal.js +179 -0
  40. package/src/operator-memory/prune.js +81 -0
  41. package/src/operator-memory/slug.js +90 -0
  42. package/src/operator-memory/storage.js +121 -0
  43. package/src/preflight-engine.js +91 -1
  44. package/src/runtime-store.js +2 -0
  45. package/template/.aioson/agents/dev.md +1 -1
  46. package/template/.aioson/agents/deyvin.md +3 -3
  47. package/template/.aioson/agents/neo.md +23 -1
  48. package/template/.aioson/agents/product.md +1 -1
  49. package/template/.aioson/agents/setup.md +1 -1
  50. package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
  51. package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
  52. package/template/AGENTS.md +23 -0
  53. package/template/CLAUDE.md +23 -0
  54. package/template/agents/_shared/memory-capture-directive.md +115 -0
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain — shared chain_audit telemetry emit helper.
5
+ *
6
+ * Single emitter used by both the CLI command (`src/commands/chain-audit.js`)
7
+ * and the post-session hook (`src/neural-chain-agent-ingest.js`) so payload
8
+ * shape is identical across code paths (BR-NC-10).
9
+ *
10
+ * Spec payload schema (BR-NC-10), 8 required fields:
11
+ * feature_slug — string | null
12
+ * source_files — string[] (the files edited in this session, plural)
13
+ * impacts_found — number | null (null on query failure)
14
+ * auto_fixable_count — number (per BR-NC-02/03 classification)
15
+ * noise_file — string | null (path written, if any)
16
+ * tokens_used — number (V1 placeholder = 0; M2 hooks LLM-mediated path)
17
+ * duration_ms — number (audit query elapsed; 0 on no-op)
18
+ * error — string | null
19
+ *
20
+ * Emitters may attach extra context fields (e.g. `agent`, `autonomy_mode`,
21
+ * `chain_auto_threshold`, `ingest_stats`, `skipped_reason`) on top of the
22
+ * required schema — those are passed through verbatim.
23
+ *
24
+ * EC-NC-05 no-op event (empty artifact list) also populates `duration_ms = 0`
25
+ * and `error = null` so downstream aggregation never has to special-case the
26
+ * skip path. Hotfix v1.17.1 — bug-found-003 from @tester gap-fill audit.
27
+ */
28
+
29
+ const REQUIRED_FIELDS = Object.freeze([
30
+ 'feature_slug',
31
+ 'source_files',
32
+ 'impacts_found',
33
+ 'auto_fixable_count',
34
+ 'noise_file',
35
+ 'tokens_used',
36
+ 'duration_ms',
37
+ 'error'
38
+ ]);
39
+
40
+ function normalizeSourceFiles(value) {
41
+ if (Array.isArray(value)) return value.slice();
42
+ if (value === null || value === undefined) return [];
43
+ return [String(value)];
44
+ }
45
+
46
+ function buildChainAuditPayload({
47
+ feature_slug = null,
48
+ source_files = [],
49
+ impacts_found = 0,
50
+ auto_fixable_count = 0,
51
+ noise_file = null,
52
+ tokens_used = 0,
53
+ duration_ms = 0,
54
+ error = null,
55
+ ...extras
56
+ } = {}) {
57
+ return {
58
+ feature_slug,
59
+ source_files: normalizeSourceFiles(source_files),
60
+ impacts_found,
61
+ auto_fixable_count,
62
+ noise_file,
63
+ tokens_used,
64
+ duration_ms,
65
+ error,
66
+ ...extras
67
+ };
68
+ }
69
+
70
+ function emitChainAuditEvent(db, { agent = null, message = 'chain:audit', ...payloadOverrides } = {}) {
71
+ if (!db || typeof db.prepare !== 'function') return false;
72
+ const payload = buildChainAuditPayload(payloadOverrides);
73
+ try {
74
+ db.prepare(`
75
+ INSERT INTO execution_events (event_type, agent_name, message, payload_json, created_at)
76
+ VALUES ('chain_audit', ?, ?, ?, ?)
77
+ `).run(agent, message, JSON.stringify(payload), new Date().toISOString());
78
+ return true;
79
+ } catch (_) {
80
+ // BR-NC-10 best-effort: telemetry failure must never propagate to the caller.
81
+ return false;
82
+ }
83
+ }
84
+
85
+ module.exports = {
86
+ buildChainAuditPayload,
87
+ emitChainAuditEvent,
88
+ normalizeSourceFiles,
89
+ REQUIRED_FIELDS
90
+ };
@@ -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
+ };